> ## Documentation Index
> Fetch the complete documentation index at: https://docs.brilo.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Call tag

# Call Tag Service

The Call Tag Service lets you manage **AI call tags** — short categorization labels (name + optional emoji) that get applied to call analyses inside a workspace. Tags are a workspace-level taxonomy: they're created once and shared across every agent in the workspace.

This page is a complete reference for the `/v1/call-tags` endpoints, plus the workflows and integration patterns most callers will need.

***

## Concepts

A **call tag** is a workspace-scoped label that can be attached to a call analysis. It has:

* A required `name` (1–64 characters)
* An optional `emoji` (max 8 characters — wide enough for combined-codepoint emoji like 🗓️)
* An audit trail (`created_at`, `updated_at`, `created_by`)
* An owning `workspace_id` (derived from your auth credentials, never sent by you)

Tags are referenced from `call_analysis.call_tag_id` (one tag per analysis). Deleting a tag with active references is blocked by default; pass `?force=true` to override and null out the references.

The tags themselves are workspace-shared: agents in the same workspace draw from the same pool. The agent's `call_tag` boolean flag (managed via the Agent API) controls whether that agent is opted into AI tag-categorization at all — it does NOT determine which specific tags the agent can use.

***

## Authentication

Every endpoint requires an `Authorization: Bearer <token>` header. Two token types are accepted:

| Token type    | Header                            | Get one from                                                                                                   | Sets `created_by`                   | Typical caller                                               |
| ------------- | --------------------------------- | -------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ------------------------------------------------------------ |
| **API key**   | `Authorization: Bearer <api-key>` | [dashboard.brilo.ai → Settings → Developer → API keys](https://dashboard.brilo.ai/settings/developer/api-keys) | `null` (no user context)            | External integrations, server-to-server scripts, MCP bridges |
| **Brilo JWT** | `Authorization: Bearer <jwt>`     | Issued automatically by brilo-app for in-product flows                                                         | `<user uuid>` of the signed-in user | brilo-app dashboard, internal tooling                        |

The server detects which type you sent by inspecting the token shape (JWTs have three dot-separated segments). You do not pass a `type` parameter.

**What's the difference, practically?** API-key callers create tags that show `created_by: null` in responses. JWT callers create tags that show `created_by: <their user_id>`. Both paths share the same workspace, so a JWT user editing in the dashboard sees the same tags an API key script created.

```bash theme={null}
# API key example
curl -H "Authorization: Bearer brilo_sk_live_abc123..." \
     https://api.brilo.ai/v1/call-tags

# JWT example (internal)
curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..." \
     https://api.brilo.ai/v1/call-tags
```

A missing or invalid token returns `401 Unauthorized`. A valid token with no workspace context also returns `401 unauthorized` with `error_code: "unauthorized"` — fail-closed by design, never falls through to an unscoped query.

***

## API versioning

The Brilo API uses **header-based versioning**. Set the `Brilo-Version` header to pin a specific contract version:

```http theme={null}
Brilo-Version: 2025-03-02
```

If you omit the header, the server defaults to the current version (`2025-03-02` as of this writing). For production integrations we recommend pinning the version explicitly — that way a future contract change won't surprise your client.

```bash theme={null}
curl -H "Authorization: Bearer $API_KEY" \
     -H "Brilo-Version: 2025-03-02" \
     https://api.brilo.ai/v1/call-tags
```

***

## Rate limits

Global throttle: **100 requests per 60 seconds per caller**, enforced across the entire `/v1/*` surface (not just call-tags). If you exceed the limit you'll see `429 Too Many Requests`.

For typical call-tag workflows this is not a constraint — taxonomy management is bursty but small. If you're bulk-seeding tags via `POST /v1/call-tags` in a loop, the idempotent semantics let you safely sleep and retry on 429 without worrying about duplicate inserts.

***

## Response envelope

Every successful response uses this wrapper:

```json theme={null}
{
  "status": 200,
  "data": { /* resource or list */ }
}
```

List responses additionally include a `pagination` block. POST responses on idempotent return include `data.created: false` (HTTP 200) vs `data.created: true` (HTTP 201) — see the create endpoint for details.

***

## Error envelope

Errors carry a stable `error_code` enum. **Branch on `error_code`, not on `message`** — the message text is human-readable and may change without notice; the code is a contract.

```json theme={null}
{
  "status": 409,
  "error_code": "tag_in_use",
  "message": "Cannot delete tag because 12 call analyses still reference it. Pass ?force=true to delete and null out the references.",
  "details": { "in_use_count": 12, "tag_id": "1f2c40b0-9c3a-4f81-b2d4-7a5e3c8f1b6c" }
}
```

| `error_code`        | HTTP | When                                                                                                                                                    | Recovery                                                                                                         |
| ------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `validation_failed` | 400  | Body or query failed validation (name length, emoji length, sort enum, etc.)                                                                            | Fix the input. The `message` field describes which constraint failed.                                            |
| `unauthorized`      | 401  | Missing/invalid token, or token resolved to no workspace                                                                                                | Re-check the `Authorization` header. Confirm your API key is active.                                             |
| `tag_not_found`     | 404  | No tag with that ID exists in your workspace (intentionally does NOT distinguish "exists in another workspace" — never leaks cross-workspace existence) | Verify the ID. If you expected it to exist, list tags to find the right ID.                                      |
| `tag_in_use`        | 409  | `DELETE` without `?force=true` and `call_analysis` references exist                                                                                     | Either retry with `?force=true` to cascade-null the references, or rename the tag instead of deleting.           |
| `duplicate_tag`     | 409  | `PUT` would collide with another tag's `(emoji, name)` in the same workspace                                                                            | The colliding tag's ID is in `details.existing_id`. Either pick a different name/emoji, or use the existing tag. |
| `internal_error`    | 500  | Unexpected server failure (also Sentry-captured)                                                                                                        | Retry with backoff. If persistent, contact support with the request ID.                                          |

***

## Field reference

All call tag responses contain these fields:

| Field          | Type                  | Nullable?        | Description                                                                                        |
| -------------- | --------------------- | ---------------- | -------------------------------------------------------------------------------------------------- |
| `id`           | string (UUID)         | no               | Stable tag identifier. Use this in subsequent calls.                                               |
| `name`         | string                | no (in practice) | Display name. 1–64 characters. Trimmed on write.                                                   |
| `emoji`        | string \| null        | yes              | Visual prefix. Max 8 characters. `null` means "no emoji." Sent as `""` clears the field on update. |
| `workspace_id` | string (UUID)         | no (in practice) | Owning workspace. Always derived server-side from your token; you cannot set it.                   |
| `created_at`   | string (ISO 8601)     | no               | Creation timestamp, UTC.                                                                           |
| `updated_at`   | string (ISO 8601)     | no               | Last-modification timestamp, UTC. An empty `PUT` body does NOT bump this (no-op short-circuits).   |
| `created_by`   | string (UUID) \| null | yes              | User who created the tag. `null` when created via API key (no user context).                       |

On the create endpoint specifically, the response data object also includes:

| Field     | Type    | Description                                                                                                                |
| --------- | ------- | -------------------------------------------------------------------------------------------------------------------------- |
| `created` | boolean | `true` if a new row was inserted (HTTP 201); `false` if an existing matching tag was returned (HTTP 200, idempotent path). |

***

## Endpoints

### Create a call tag (idempotent)

Creates a new tag in your workspace, OR returns the existing tag if an exact `(emoji, name)` match already exists. This idempotent semantic means AI agents and MCP clients can retry POST safely without writing a separate "get-or-create" workflow.

```http theme={null}
POST /v1/call-tags
```

**Request body:**

| Field   | Type   | Required | Description                                                                   |
| ------- | ------ | -------- | ----------------------------------------------------------------------------- |
| `name`  | string | Yes      | Display name. 1–64 characters. Whitespace trimmed.                            |
| `emoji` | string | No       | Single emoji or short prefix. Max 8 characters. Omit or send `null` for none. |

**Examples:**

```bash theme={null}
# curl
curl -X POST https://api.brilo.ai/v1/call-tags \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"Scheduling","emoji":"📅"}'
```

```javascript theme={null}
// JavaScript (fetch)
const resp = await fetch('https://api.brilo.ai/v1/call-tags', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.BRILO_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ name: 'Scheduling', emoji: '📅' }),
});
const body = await resp.json();
if (body.data.created) {
  console.log('Created new tag', body.data.id);
} else {
  console.log('Existing tag returned', body.data.id);
}
```

```python theme={null}
# Python (requests)
import os, requests

resp = requests.post(
    'https://api.brilo.ai/v1/call-tags',
    headers={'Authorization': f'Bearer {os.environ["BRILO_API_KEY"]}'},
    json={'name': 'Scheduling', 'emoji': '📅'},
)
body = resp.json()
print('created' if body['data']['created'] else 'existing', body['data']['id'])
```

**Response — newly created (HTTP 201):**

```json theme={null}
{
  "status": 201,
  "data": {
    "id": "1f2c40b0-9c3a-4f81-b2d4-7a5e3c8f1b6c",
    "name": "Scheduling",
    "emoji": "📅",
    "workspace_id": "abc12345-6789-4def-ghij-klmnopqrstuv",
    "created_at": "2026-05-22T19:10:17.000Z",
    "updated_at": "2026-05-22T19:10:17.000Z",
    "created_by": "user-uuid-here",
    "created": true
  }
}
```

**Response — existing tag returned (HTTP 200, idempotent path):**

```json theme={null}
{
  "status": 200,
  "data": {
    "id": "1f2c40b0-9c3a-4f81-b2d4-7a5e3c8f1b6c",
    "name": "Scheduling",
    "emoji": "📅",
    "workspace_id": "abc12345-6789-4def-ghij-klmnopqrstuv",
    "created_at": "2026-05-20T08:00:00.000Z",
    "updated_at": "2026-05-20T08:00:00.000Z",
    "created_by": "user-uuid-here",
    "created": false
  }
}
```

Inspect `data.created` to distinguish "newly created" from "existing returned" without parsing the HTTP status code — useful for clients that don't expose status to your code.

**Concurrent retry semantics:** Two clients sending the same `POST {"name":"Scheduling","emoji":"📅"}` simultaneously will both succeed. Whichever loses the race against the unique constraint catches the conflict internally and re-fetches the winner. Both clients see the same row in their response.

### List call tags

Returns a paginated list of tags in your workspace, with optional case-insensitive substring search and result ordering.

```http theme={null}
GET /v1/call-tags
```

**Query parameters:**

| Param    | Type    | Default           | Description                                                                                                                       |
| -------- | ------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `offset` | integer | `0`               | Number of rows to skip (0-indexed).                                                                                               |
| `limit`  | integer | `50`              | Page size. Server caps at 200 — larger values are silently clamped.                                                               |
| `q`      | string  | (none)            | Case-insensitive substring match on `name`. `%` and `_` are treated as literal characters, not SQL wildcards. Max 100 characters. |
| `sort`   | enum    | `created_at:desc` | One of: `created_at:desc`, `created_at:asc`, `name:asc`, `name:desc`.                                                             |

**Examples:**

```bash theme={null}
# Default page (newest first, 50 at a time)
curl -H "Authorization: Bearer $API_KEY" \
     "https://api.brilo.ai/v1/call-tags"

# Search by substring, sorted alphabetically
curl -H "Authorization: Bearer $API_KEY" \
     "https://api.brilo.ai/v1/call-tags?q=schedule&sort=name:asc"

# Walk page 2 of 25-at-a-time
curl -H "Authorization: Bearer $API_KEY" \
     "https://api.brilo.ai/v1/call-tags?offset=25&limit=25"
```

```javascript theme={null}
// JavaScript — iterate all tags
async function* listAllTags(apiKey) {
  let offset = 0;
  const limit = 100;
  while (true) {
    const resp = await fetch(
      `https://api.brilo.ai/v1/call-tags?offset=${offset}&limit=${limit}`,
      { headers: { Authorization: `Bearer ${apiKey}` } },
    );
    const body = await resp.json();
    for (const tag of body.data) yield tag;
    if (!body.pagination.has_more) break;
    offset += body.pagination.limit;
  }
}

for await (const tag of listAllTags(process.env.BRILO_API_KEY)) {
  console.log(tag.emoji ?? '·', tag.name);
}
```

```python theme={null}
# Python — iterate all tags
def list_all_tags(api_key):
    offset, limit = 0, 100
    while True:
        resp = requests.get(
            'https://api.brilo.ai/v1/call-tags',
            headers={'Authorization': f'Bearer {api_key}'},
            params={'offset': offset, 'limit': limit},
        )
        body = resp.json()
        yield from body['data']
        if not body['pagination']['has_more']:
            break
        offset += body['pagination']['limit']
```

**Response:**

```json theme={null}
{
  "status": 200,
  "data": [
    {
      "id": "1f2c40b0-9c3a-4f81-b2d4-7a5e3c8f1b6c",
      "name": "Scheduling",
      "emoji": "📅",
      "workspace_id": "abc12345-6789-4def-ghij-klmnopqrstuv",
      "created_at": "2026-05-22T19:10:17.000Z",
      "updated_at": "2026-05-22T19:10:17.000Z",
      "created_by": "user-uuid-here"
    }
  ],
  "pagination": {
    "offset": 0,
    "limit": 50,
    "total": 127,
    "has_more": true
  }
}
```

`pagination.total` is the count after applying `q` filter but ignoring pagination — so you can render "showing 1–50 of 127 matching" without an extra count call.

`has_more: true` means more rows exist beyond the returned page. Loop until `has_more: false` for full enumeration.

### Get a call tag by ID

Retrieves a single tag.

```http theme={null}
GET /v1/call-tags/{id}
```

| Parameter | Type          | Required | Description                                                                                                                   |
| --------- | ------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `id`      | string (UUID) | Yes      | Tag ID. Returns `400 validation_failed` for malformed UUIDs (handled by the route's UUID parser, never reaches the database). |

**Example:**

```bash theme={null}
curl -H "Authorization: Bearer $API_KEY" \
     https://api.brilo.ai/v1/call-tags/1f2c40b0-9c3a-4f81-b2d4-7a5e3c8f1b6c
```

**Response:**

```json theme={null}
{
  "status": 200,
  "data": {
    "id": "1f2c40b0-9c3a-4f81-b2d4-7a5e3c8f1b6c",
    "name": "Scheduling",
    "emoji": "📅",
    "workspace_id": "abc12345-6789-4def-ghij-klmnopqrstuv",
    "created_at": "2026-05-22T19:10:17.000Z",
    "updated_at": "2026-05-22T19:10:17.000Z",
    "created_by": "user-uuid-here"
  }
}
```

**Cross-workspace privacy:** If the tag exists but belongs to another workspace, the response is `404 tag_not_found` — never `403 Forbidden`. This is intentional: distinguishing the two would leak the existence of cross-workspace UUIDs. We treat "not in your workspace" identically to "doesn't exist anywhere."

### Update a call tag (partial)

Modifies an existing tag. Only the fields you send are changed — omit a field to leave it unchanged.

```http theme={null}
PUT /v1/call-tags/{id}
```

| Parameter | Type          | Required | Description |
| --------- | ------------- | -------- | ----------- |
| `id`      | string (UUID) | Yes      | Tag ID.     |

**Request body (all fields optional):**

| Field   | Type   | Description                                                                                       |
| ------- | ------ | ------------------------------------------------------------------------------------------------- |
| `name`  | string | New display name. 1–64 chars. Omit to leave unchanged.                                            |
| `emoji` | string | New emoji. Max 8 chars. Omit to leave unchanged. **Send empty string (`""`) to clear** the emoji. |

**Empty-string emoji semantics:**

| Body             | Effect on emoji                                     |
| ---------------- | --------------------------------------------------- |
| Field absent     | Unchanged                                           |
| `"emoji": "🗓️"` | Set to `"🗓️"`                                      |
| `"emoji": ""`    | Cleared (response shows `null`)                     |
| `"emoji": null`  | Rejected as `validation_failed` (use `""` to clear) |

**Empty body short-circuit:** A `PUT` with `{}` returns the existing tag unchanged and does NOT bump `updated_at`. Useful for "ensure this tag exists" patterns where you don't actually want to modify anything.

**Examples:**

```bash theme={null}
curl -X PUT https://api.brilo.ai/v1/call-tags/1f2c40b0-... \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"Scheduling & Booking","emoji":"🗓️"}'

# Clear emoji
curl -X PUT https://api.brilo.ai/v1/call-tags/1f2c40b0-... \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"emoji":""}'
```

```python theme={null}
# Python
requests.put(
    f'https://api.brilo.ai/v1/call-tags/{tag_id}',
    headers={'Authorization': f'Bearer {api_key}'},
    json={'name': 'Scheduling & Booking', 'emoji': '🗓️'},
)
```

**Response:**

```json theme={null}
{
  "status": 200,
  "data": {
    "id": "1f2c40b0-9c3a-4f81-b2d4-7a5e3c8f1b6c",
    "name": "Scheduling & Booking",
    "emoji": "🗓️",
    "workspace_id": "abc12345-6789-4def-ghij-klmnopqrstuv",
    "created_at": "2026-05-22T19:10:17.000Z",
    "updated_at": "2026-05-22T20:00:00.000Z",
    "created_by": "user-uuid-here"
  }
}
```

**Error — duplicate collision (HTTP 409):**

If your update would collide with another tag's `(emoji, name)` in the same workspace:

```json theme={null}
{
  "status": 409,
  "error_code": "duplicate_tag",
  "message": "Another tag in this workspace already uses that (emoji, name) combination.",
  "details": { "existing_id": "other-tag-uuid" }
}
```

The `details.existing_id` is the colliding row's ID — use it to either delete-and-merge or pick a different name.

### Delete a call tag

Removes a tag. By default, refuses to delete a tag that is still referenced by any `call_analysis` rows — this preserves analytics history. Pass `?force=true` to override and null out the references.

```http theme={null}
DELETE /v1/call-tags/{id}
```

| Parameter | Type          | Required | Description |
| --------- | ------------- | -------- | ----------- |
| `id`      | string (UUID) | Yes      | Tag ID.     |

**Query parameter:**

| Param   | Type    | Default | Description                                                                                                                                                            |
| ------- | ------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `force` | boolean | `false` | When `true`, atomically null `call_analysis.call_tag_id` for all referencing rows and delete the tag. When `false`, refuses with `409 tag_in_use` if references exist. |

**Examples:**

```bash theme={null}
# Safe delete (refuses if in use)
curl -X DELETE -H "Authorization: Bearer $API_KEY" \
     https://api.brilo.ai/v1/call-tags/1f2c40b0-...

# Force-delete (always succeeds, nulls referencing analyses)
curl -X DELETE -H "Authorization: Bearer $API_KEY" \
     "https://api.brilo.ai/v1/call-tags/1f2c40b0-...?force=true"
```

**Response — success when not in use (HTTP 200):**

```json theme={null}
{
  "status": 200,
  "data": {
    "id": "1f2c40b0-9c3a-4f81-b2d4-7a5e3c8f1b6c",
    "deleted": true
  }
}
```

**Response — force-delete with cascaded references (HTTP 200):**

```json theme={null}
{
  "status": 200,
  "data": {
    "id": "1f2c40b0-9c3a-4f81-b2d4-7a5e3c8f1b6c",
    "deleted": true,
    "cascaded_count": 12
  }
}
```

`cascaded_count` is the exact number of `call_analysis` rows whose `call_tag_id` was set to `null`. It appears only when `?force=true` was used AND references actually existed.

**Error — tag in use (HTTP 409):**

```json theme={null}
{
  "status": 409,
  "error_code": "tag_in_use",
  "message": "Cannot delete tag because 12 call analyses still reference it. Pass ?force=true to delete and null out the references.",
  "details": {
    "in_use_count": 12,
    "tag_id": "1f2c40b0-9c3a-4f81-b2d4-7a5e3c8f1b6c"
  }
}
```

Use `details.in_use_count` to show the user a "12 analyses use this tag — really delete?" confirmation before retrying with `?force=true`.

**Concurrency guarantee:** Delete runs at Serializable isolation. A concurrent `call_analysis` insert that arrives between the in-use check and the actual delete cannot silently slip through — it either becomes part of the count (and triggers `409 tag_in_use`) or causes a serialization-failure retry. You will never lose an analysis-tag association silently to a delete race.

***

## Workflows and recipes

### Seed a starter taxonomy for a new workspace

Bulk-create a curated tag set when onboarding a new workspace. Because POST is idempotent, you can re-run this safely if it's interrupted partway through.

```python theme={null}
import requests, time

STARTER_TAGS = [
    {'emoji': '📅', 'name': 'Appointment Booking'},
    {'emoji': '❓', 'name': 'Treatment Question'},
    {'emoji': '🚨', 'name': 'Emergency'},
    {'emoji': '💰', 'name': 'Billing & Insurance'},
    {'emoji': '📞', 'name': 'New Patient'},
]

def seed_starter_tags(api_key, base_url='https://api.brilo.ai'):
    created, existed = [], []
    for tag in STARTER_TAGS:
        while True:
            resp = requests.post(
                f'{base_url}/v1/call-tags',
                headers={'Authorization': f'Bearer {api_key}'},
                json=tag,
            )
            if resp.status_code == 429:
                time.sleep(1)
                continue
            resp.raise_for_status()
            row = resp.json()['data']
            (created if row['created'] else existed).append(row['name'])
            break
    print(f'created {len(created)}: {created}')
    print(f'already existed {len(existed)}: {existed}')
```

### Find calls tagged with a specific tag

The `call_analysis` table is the join. Once you have a tag ID, query call analyses filtered by it via the `/v1/call` endpoints (see the Call Service page). The tag ID never changes after creation, so you can store it persistently in your downstream system.

### Rename a tag without losing history

A simple `PUT` rename keeps the same tag ID — all existing `call_analysis.call_tag_id` references continue to point at the (now renamed) tag. No data migration needed.

```bash theme={null}
curl -X PUT https://api.brilo.ai/v1/call-tags/$TAG_ID \
  -H "Authorization: Bearer $API_KEY" \
  -d '{"name":"New Name"}'
```

If you instead created a new tag and deleted the old one, you would lose the association on every analysis that referenced the old tag (the FK cascade would null them out on force-delete). **Renaming is almost always the right move.**

### Audit recently-created tags

Sort by `created_at:desc` to see what was added recently — useful for catching tag-bloat from automated agents.

```bash theme={null}
curl -H "Authorization: Bearer $API_KEY" \
     "https://api.brilo.ai/v1/call-tags?sort=created_at:desc&limit=20"
```

### Bulk-clean unused tags

There's no native "unused tags" endpoint, but you can compute the set client-side:

1. List all workspace tags via `GET /v1/call-tags`.
2. For each tag, attempt `DELETE` with NO `force` flag.
3. Tags with no references delete successfully (HTTP 200). Tags in use return `409 tag_in_use` and are left alone.

```python theme={null}
def delete_unused_tags(api_key, base_url='https://api.brilo.ai'):
    deleted, in_use = [], []
    for tag in list_all_tags(api_key):
        resp = requests.delete(
            f'{base_url}/v1/call-tags/{tag["id"]}',
            headers={'Authorization': f'Bearer {api_key}'},
        )
        if resp.status_code == 200:
            deleted.append(tag['name'])
        elif resp.status_code == 409 and resp.json().get('error_code') == 'tag_in_use':
            in_use.append((tag['name'], resp.json()['details']['in_use_count']))
        else:
            resp.raise_for_status()
    print(f'deleted {len(deleted)} unused tags')
    print(f'kept {len(in_use)} tags in use:')
    for name, count in in_use:
        print(f'  {name}: {count} analyses')
```

***

## Integration notes for AI agents and MCP

The `/v1/call-tags` surface is designed to be consumed cleanly by AI agents and MCP (Model Context Protocol) bridges. Five practical notes:

### 1. Stable operationIds map to MCP tool names

Each endpoint has a camelCase `operationId` in the OpenAPI spec. Standard OpenAPI-to-MCP bridges convert these directly to MCP tool names:

| `operationId`   | MCP tool name (typical) |
| --------------- | ----------------------- |
| `createCallTag` | `create_call_tag`       |
| `listCallTags`  | `list_call_tags`        |
| `getCallTag`    | `get_call_tag`          |
| `updateCallTag` | `update_call_tag`       |
| `deleteCallTag` | `delete_call_tag`       |

### 2. Idempotent POST = safe to retry without dedup

POST is idempotent on `(emoji, name)`. If your agent retries a tag-creation step (e.g., after a timeout), the retry returns the existing tag — no `409 duplicate`, no duplicate rows. Branch on `data.created` to know whether your retry was the one that actually inserted.

### 3. Branch on `error_code`, not on `message`

Recommended agent error-handling pattern:

```python theme={null}
ERROR_RECOVERY = {
    'validation_failed': 'fix-input',
    'unauthorized': 'refresh-token',
    'tag_not_found': 'rediscover-tag',  # tag may have been deleted
    'tag_in_use': 'ask-user-or-force',   # show user the in_use_count
    'duplicate_tag': 'use-existing',     # use details.existing_id
    'internal_error': 'retry-with-backoff',
}

resp = requests.post('...', headers=..., json=...)
if resp.status_code >= 400:
    code = resp.json().get('error_code')
    strategy = ERROR_RECOVERY.get(code, 'escalate-to-human')
    handle(strategy, resp.json())
```

The `error_code` enum is part of the contract. Messages are human-readable and may change between versions; codes are stable.

### 4. Pagination has a known `total`, so agents can plan exhaustive scans

Unlike cursor-based APIs, the `pagination.total` field lets an agent compute "I have N pages to walk" upfront and budget accordingly. Combined with the server max of 200 per page, the worst-case iteration cost is `ceil(total / 200)` requests.

### 5. Force-delete `cascaded_count` is exact, not a pre-count

When you `DELETE ?force=true`, the response's `cascaded_count` is the EXACT number of `call_analysis` rows that were nulled — computed inside the same transaction as the delete, not from a stale pre-check. Agents can rely on this number for downstream reconciliation (e.g., "I just orphaned 12 analyses; my downstream cache should drop their tag-derived enrichments").

***

## Troubleshooting matrix

| Symptom                                      | Likely cause                                            | Fix                                                                                             |
| -------------------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `401 unauthorized` immediately               | API key not sent / invalid / inactive                   | Confirm `Authorization: Bearer ...` header. Regenerate the key from the dashboard if needed.    |
| `401 unauthorized` after weeks of working    | API key revoked, or workspace deleted                   | Check dashboard for key status. Issue a new key.                                                |
| `400 validation_failed` on `?offset=abc`     | Query param expected an integer                         | Send numeric values. The server requires explicit numeric coercion on these params.             |
| `400 validation_failed` on a UUID path       | Malformed UUID in `/v1/call-tags/{id}`                  | Verify the ID is a 36-character UUID. Don't send tag names or numeric IDs.                      |
| `404 tag_not_found` on an ID you know exists | The tag exists in a DIFFERENT workspace, or was deleted | List your workspace's tags to confirm. We do NOT distinguish "exists elsewhere" from "deleted." |
| `409 tag_in_use` on DELETE                   | Tag still has `call_analysis` references                | Retry with `?force=true` (cascade-null), or rename instead of deleting.                         |
| `409 duplicate_tag` on PUT                   | New `(emoji, name)` would collide with another tag      | `details.existing_id` is the colliding row. Pick a different name, or use that tag.             |
| POST returns `created: false` unexpectedly   | Idempotent path — the tag already existed               | Working as designed. Use `data.id` to reference the existing tag.                               |
| `429 Too Many Requests`                      | 100/60s global rate limit hit                           | Sleep and retry. POST is safe to retry without duplicates.                                      |
| `500 internal_error`                         | Unexpected server issue (Sentry-captured)               | Retry with exponential backoff. If persistent, contact support with the response timestamp.     |

***

## Concurrency semantics (advanced)

The endpoints handle concurrent operations cleanly:

| Scenario                                                                     | Behavior                                                                                                                                                                                                                                                              |
| ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Two POSTs with identical `(emoji, name)` arrive simultaneously               | Both succeed. One inserts (HTTP 201, `created: true`); the other catches the unique-constraint conflict, re-fetches the winner, and returns HTTP 200 with `created: false`. No duplicate rows.                                                                        |
| POST and DELETE on the same tag race                                         | The POST may either find the existing row (if it ran first) or insert a new row (if DELETE went first). In the rare case the POST loses to a P2002-then-rollback, you receive `409 duplicate_tag` — safe to retry.                                                    |
| Two PUTs on the same tag with conflicting `(emoji, name)`                    | Last-write-wins on non-collision fields. On collision, one PUT returns `409 duplicate_tag` with the colliding `existing_id`.                                                                                                                                          |
| DELETE without force + concurrent `call_analysis` insert referencing the tag | Delete runs at Serializable isolation. Either the count includes the new analysis (and DELETE returns `409 tag_in_use`), or the database surfaces a serialization conflict and the DELETE retries once. **You will never silently lose an analysis-tag association.** |
| DELETE with force + concurrent inserts                                       | The `cascaded_count` in the response is the EXACT number of rows nulled in the same transaction. Concurrent inserts arriving after the delete completes are not affected (the parent tag is already gone).                                                            |

***

## Performance characteristics

* **GET (single tag):** \~5–10ms typical, dominated by Prisma roundtrip. Constant time regardless of workspace size.
* **GET (list, no q):** \~10–20ms for workspaces with fewer than 500 tags. Linear in workspace tag count.
* **GET (list with q):** Uses Postgres `ILIKE %q%`, which cannot use a btree index. Linear in workspace tag count. Acceptable for workspaces with fewer than 1000 tags; if you operate at higher scale and see slow list responses, contact us — a trigram (`pg_trgm`) GIN index is planned for that case.
* **POST:** \~15–30ms typical (one read for idempotency check + one insert). The race-handling P2002 path adds \~10ms when triggered.
* **PUT:** \~10–20ms typical (one read + one update).
* **DELETE without force:** \~15–25ms (one read + one count + one delete inside a transaction).
* **DELETE with force:** Proportional to `cascaded_count`. \~25ms for a tag with 0 references; up to \~500ms for a tag with 10,000 references. Tags with hundreds of thousands of references should be handled out-of-band (contact us).

The 100 req/60s rate limit is the primary capacity constraint for bulk operations.

***

## Sample end-to-end session

A full lifecycle, top to bottom — useful as a smoke test.

```bash theme={null}
API="https://api.brilo.ai"
H="-H Authorization:\ Bearer\ $BRILO_API_KEY -H Content-Type:\ application/json"

# 1. Create a tag
curl -X POST $API/v1/call-tags $H \
  -d '{"name":"Scheduling","emoji":"📅"}'
# → 201, data.created: true, data.id: TAG_ID

# 2. Retry — confirms idempotency
curl -X POST $API/v1/call-tags $H \
  -d '{"name":"Scheduling","emoji":"📅"}'
# → 200, data.created: false, same TAG_ID

# 3. Find it in the list
curl $H "$API/v1/call-tags?q=schedul"
# → 200, includes the tag

# 4. Read by ID
curl $H "$API/v1/call-tags/TAG_ID"
# → 200

# 5. Rename
curl -X PUT $API/v1/call-tags/TAG_ID $H \
  -d '{"name":"Scheduling & Booking"}'
# → 200, updated_at bumped

# 6. Try to delete without force (assume no call_analysis references yet)
curl -X DELETE $H "$API/v1/call-tags/TAG_ID"
# → 200, data.deleted: true

# 7. Confirm gone
curl $H "$API/v1/call-tags/TAG_ID"
# → 404, error_code: tag_not_found
```

***

## Changelog

| Version      | Date    | Changes                                                                                                                                                      |
| ------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `2025-03-02` | initial | Endpoints introduced. Idempotent POST, partial PUT, in-use-blocking DELETE with force flag, error\_code enum, pagination + search + sort, full audit fields. |
