# Agent Directory

> The unified human and LLM agent roster: status vocabulary, hierarchy assignments, skills, bulk actions, deactivate vs delete, and the seat cap.

The agent directory is the roster screen for an org. It reads `dim_agent`
through the `adminAgents` tRPC router and renders one row per agent,
human or LLM. From here you filter the roster, reassign people in bulk,
export a CSV, and open a detail page. The **New agent** wizard writes
into the same table.

Everything on the screen is scoped to the active org
(`tenant.activeOrg.id`). Every query and mutation takes that `orgId`
explicitly.

## The roster: human and LLM agents in one table

TeleQuick keeps both kinds of worker in a single roster row so that
reporting, hierarchy, and hotline assignment work identically for a
person and for an AI agent. The `kind` column picks one of two
field sets on the same table:

| Field group | `kind = 'human'` | `kind = 'llm'` |
| ----------- | ---------------- | -------------- |
| Shared | `display_name`, `external_agent_id`, `site_id`, `team_manager_id`, `team_leader_id`, `primary_hotline_id`, `status` | same |
| Kind-specific | `user_id`, `staff_id`, `hire_date`, `position`, `agency`, `employee_type`, `email` | `agent_config_id`, `default_provider`, `default_model`, `system_prompt_version`, `hourly_cost_paise` |

Switching kind in the wizard clears the fields that do not apply to the
new kind, so an LLM agent never carries a stale `staff_id` and a human
never carries a stale `default_model`.

The header strip above the table shows the counters the list procedure
returns alongside the rows: `total`, `active`, `humans`, `llms`,
`onboarding`, `on_leave`, `suspended`. When the roster query is still
loading or has failed, the subtitle reads `loading…` / `unavailable`
rather than a count — a failed fetch must not look like an empty org.

LLM rows, the Humans / LLMs counter, and the Human/LLM kind chips are
hidden when the `showLLM` tweak is off. With the tweak off, the list
query is pinned to `kind: 'human'`.

## Identity fields and the external agent ID

Two identity fields are required for every agent, both kinds:

- **Display name** — what the table, avatar, and every report show.
- **External agent ID** — your own identifier for the agent (the dialer
  or workforce system's agent id). It renders in monospace under the
  display name and is one of the fields the search box matches.

Human agents additionally validate `email` as an email address if
present. LLM agents instead require an agent pool (see below). The
wizard hides these messages until your first **Continue** or **Create
agent**, then tracks each field live; if a required identity field is
still missing when you press **Create agent**, the wizard jumps back to
the Identity step.

Optional human fields on the Identity step: `staff_id`, `position`,
`employee_type`. `employee_type` is one of:

| Value | Label |
| ----- | ----- |
| `fte` | FTE |
| `contractor` | Contractor |
| `intern` | Intern |
| `temp` | Temp |
| `other` | Other |

The **Last login** column is not backed by a server field yet and
renders `—` for every row.

## Status vocabulary and the Inactive flag

`status` is the workflow state of the agent. The Status filter also
offers **Inactive**, which is *not* a status — it is the soft-delete
flag.

| Filter value | Meaning |
| ------------ | ------- |
| Any | No status constraint. |
| `active` | Working and dialable. The directory defaults to this filter. |
| Inactive | Soft-deleted. Maps to the list procedure's `active: false` flag, not to `status`. |
| `onboarding` | Created but not yet live. The wizard's other allowed initial status. |
| `on_leave` | Temporarily away. |
| `paused` | Temporarily not taking calls. |
| `suspended` | Blocked from taking calls. |
| `terminated` | Left the org. What **Deactivate** sets. |

The distinction that matters: **Inactive** is the row-level soft-delete
flag (`active = false`), while **Terminated** is an employment status.
Selecting Inactive clears the status constraint and filters on the flag
instead; the two are never sent together.

The wizard can only create an agent as `active` or `onboarding`.

## Assignments: site, team manager, team leader, hotline

Four nullable foreign keys place an agent in the org hierarchy. The
directory resolves three of them to human-readable codes using lookup
queries run in parallel on mount:

| Field | Lookup | Rendered as |
| ----- | ------ | ----------- |
| `site_id` | `adminAgents.listSites` | Site `code` chip |
| `team_manager_id` | `adminTeamManagers.list` (wizard only) | — |
| `team_leader_id` | `adminAgents.listTeamLeaders` | TL `name` |
| `primary_hotline_id` | `adminAgents.listHotlines` | Hotline `code`, monospace |

Any unresolved or unset key renders `—`. All four are optional at
creation; you can leave the whole Hierarchy step untouched and fill it
in later from the agent's detail page.

Site and hotline lookups expose both a `name` and a `code`: the name is
the label in pickers, the code is the hint and the column value.

## Linking an LLM agent to an agent pool

For `kind = 'llm'`, `agent_config_id` is required. Rows in
`agent_config` **are** the agent pools — the AI voice-agent configs the
runtime binds to. The wizard loads the org's pools
(`agent_config.id, name`, ordered by `created_at`) and presents them as
a picker, so you select a pool rather than paste a raw id.

Alongside the pool, an LLM agent can carry `default_provider`,
`default_model`, and `hourly_cost_paise`. These are optional and are
sent as `null` when blank.

Because the roster row points at the config, deleting an LLM agent also
unpublishes its config — see [Deactivate vs Delete](#deactivate-vs-delete).

## Skill assignments

The wizard's Skills step picks from `adminSkills.list` and attaches each
choice with two integers:

| Field | What it does |
| ----- | ------------ |
| `priority` | Ordering of this skill relative to the agent's other skills. |
| `level` | The agent's proficiency in this skill. |

Skills are written **after** the agent row exists: the wizard creates
the agent, takes the returned `id`, then fires one
`adminAgents.upsertAgentSkill` call per skill in parallel. Failures are
tolerated per row. If some succeed and some fail you get a warning toast
naming the counts, the agent is still created, and you retry the failed
ones from the agent's Skills tab. `upsertAgentSkill` is an upsert, so
retrying is safe.

## Filtering, search, and pagination

All filtering happens server-side. The list procedure accepts:

| Input | Source in the UI |
| ----- | ---------------- |
| `search` | Search box — matches name, email, and ID. Trimmed; omitted when empty. |
| `kind` | Human / LLM chip, or forced to `human` when the LLM tweak is off. `All` sends nothing. |
| `status` | Status filter, except for Inactive. |
| `active` | `false` when the Status filter is Inactive. |
| `siteId` | Site filter. |
| `teamLeaderId` | TL filter. |
| `hotlineId` | Hotline filter. |
| `employment` | Employment filter. |
| `page`, `perPage` | Pager. The directory requests `perPage: 50`. |

Changing any filter or the search text resets `page` to 1. The response
carries `rows`, `total`, `perPage`, and `counters`; the pager derives the
page count from `total / perPage`.

The previous page's data stays on screen while the next page loads
(`keepPreviousData`), so the table does not blank out between pages. The
lookup queries for sites, hotlines, and team leaders are cached and
shared by the filter chips and the FK columns. The header shows the
timestamp of the last successful fetch and a refresh button that
refetches the roster.

## Bulk reassignment

Select rows with the checkbox column to reveal the bulk bar. Two of its
actions are reassignments, both implemented with
`adminAgents.bulkUpdate` over the selected ids:

| Action | Patch sent |
| ------ | ---------- |
| Change TL | `{ team_leader_id }` from the team-leader picker |
| Change site | `{ site_id }` from the site picker |

Both prompt you to pick the new value first; cancelling the picker
cancels the mutation. On success the roster is invalidated and
refetched, the selection clears, and a toast reports the `updated`
count returned by the procedure.

## Deactivate vs Delete

These are not two flavours of the same thing. Choose based on whether
you still need the agent's history to be readable.

**Deactivate** (`adminAgents.deactivate`) marks the selected agents
terminated with today's `end_date` and accepts an optional free-text
reason (for example `resigned`, `attrited`, `layoff`). The rows stay in
`dim_agent`, so historical reporting joins still resolve the agent's
name and attributes. The success toast reports the `deactivated` count.
This is the action to use when you only want to stop someone taking
calls.

**Delete** (`adminAgents.delete`) drops the rows permanently and cannot
be undone. Historical reporting that joins against those agents loses
their name and attributes. For LLM agents, deletion also unpublishes the
linked LLM configs — the response returns both `deleted` and
`configsDeleted`, and the toast reports the number of configs
unpublished.

Both actions confirm first, and Delete's confirmation explicitly points
you at Deactivate as the reversible alternative.

## CSV export columns

Two exports are available, both generated in the browser from the rows
currently rendered:

- **Export CSV** in the filter bar — the current page, written to
  `agents-page-<n>.csv`.
- **Export selected** in the bulk bar — the selected rows, written to
  `agents-selected.csv`.

Because both work from the rendered rows, the export reflects the active
filters and the current page, and it contains the denormalised display
values rather than raw ids:

| Column | Source |
| ------ | ------ |
| `kind` | `kind` (`human` / `llm`) |
| `displayName` | `display_name` |
| `externalAgentId` | `external_agent_id` |
| `email` | `email` |
| `siteCode` | `site_id` resolved to the site's `code` |
| `tlName` | `team_leader_id` resolved to the TL's `name` |
| `hotlineCode` | `primary_hotline_id` resolved to the hotline's `code` |
| `status` | `status` |
| `position` | `position` |
| `employment` | `employee_type` |
| `agency` | `agency` |
| `lastLogin` | Always `—` (not modelled server-side) |

## Seat caps and what happens at the limit

Roster size is bounded by the org's entitlement claim
`limits.max_concurrent_agents`. The wizard reads the cap from the
entitlement hook and the current roster size from the same `counters.total`
the directory header uses.

When both numbers are known and the cap is a positive number, the wizard
treats the org as at capacity once the total reaches the cap. In that
state:

- **Create agent** is disabled and relabelled with the count and the cap.
- Hovering it explains that the limit is reached and that you need to
  upgrade the plan.
- Submitting anyway surfaces an inline error naming the count, the cap,
  and the upgrade path.

This check is optimistic and client-side only: it does nothing until
both the cap and the count have loaded. The gateway enforces the hard
limit regardless of what the console shows.

## Related

- [Authentication](/concepts/authentication) — org-scoped API keys used by the control-plane API
- [Telephony Metrics](/glossary/metrics) — the reporting that agent rows join against
