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)
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 anAuthorization: Bearer <token> header. Two token types are accepted:
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.
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 theBrilo-Version header to pin a specific contract 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.
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: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 stableerror_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.
Field reference
All call tag responses contain these fields:
On the create endpoint specifically, the response data object also includes:
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.
Examples:
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.
Examples:
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.
Example:
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.
Request body (all fields optional):
Empty-string emoji semantics:
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:
(emoji, name) in the same workspace:
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 anycall_analysis rows — this preserves analytics history. Pass ?force=true to override and null out the references.
Query parameter:
Examples:
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):
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.Find calls tagged with a specific tag
Thecall_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 simplePUT 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.
Audit recently-created tags
Sort bycreated_at:desc to see what was added recently — useful for catching tag-bloat from automated agents.
Bulk-clean unused tags
There’s no native “unused tags” endpoint, but you can compute the set client-side:- List all workspace tags via
GET /v1/call-tags. - For each tag, attempt
DELETEwith NOforceflag. - Tags with no references delete successfully (HTTP 200). Tags in use return
409 tag_in_useand are left alone.
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 camelCaseoperationId in the OpenAPI spec. Standard OpenAPI-to-MCP bridges convert these directly to MCP tool names:
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:
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
Concurrency semantics (advanced)
The endpoints handle concurrent operations cleanly: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).
