Labels, notes and canned replies
Three small tenant-scoped resources that hang off conversations: a shared label taxonomy, internal
notes, and reusable reply text. All are @RequireScopes('inbox') except the two bulk import routes.
Labels
InboxLabel is { name, color? }, unique on (tenantId, name) — one taxonomy per workspace.
A label is applied to a thread or to a ticket, through two separate join tables that point at
the same InboxLabel. So a new "urgent" label is defined once and usable in both places, while the
attachment records which of the two it landed on.
Tickets are the primary surface. In axis-api the split was 13,350 ticket labels against 1,289 conversation labels — roughly 10:1 — because labelling is a property of the support session, not the whole conversation. A thread that ran three tickets can carry three different label sets over its life, and collapsing those onto the thread would erase which session each tag described. Thread labels stay because the 1,289 still exist and still migrate.
appliedByUserId null means AI
Both join tables carry a nullable appliedByUserId. Null means an AI agent applied the label, and
that is what the UI reads to show "tagged by the agent" — directly relevant when the point of the
exercise is measuring AI behaviour. It is nullable by design, not by omission. A machine key also
produces null, so if you need human attribution, call with a session.
Apply versus replace
| Route | Semantics |
|---|---|
POST /v1/threads/:threadId/labels |
Apply one label (body { labelId }). Idempotent — re-applying returns the same set. 200 |
PUT /v1/threads/:threadId/labels |
Replace the whole set. 200 |
DELETE /v1/threads/:threadId/labels/:labelId |
204 |
GET /v1/threads/:threadId/labels |
The PUT is applied as a diff, not delete-all-then-insert, so a label that survives the replace keeps
its original appliedByUserId and createdAt. Without that, re-saving an unchanged set would rewrite
every attachment's author to whoever hit save.
Ticket labels mirror the thread routes exactly — same idempotency, same author convention:
| Method | Path |
|---|---|
| GET | /v1/tickets/:ticketId/labels |
| POST | /v1/tickets/:ticketId/labels |
| DELETE | /v1/tickets/:ticketId/labels/:labelId |
There is no PUT on the ticket side.
Taxonomy CRUD
| Method | Path | Status |
|---|---|---|
| GET | /v1/labels |
200 |
| POST | /v1/labels |
201 |
| PATCH | /v1/labels/:labelId |
200 |
| DELETE | /v1/labels/:labelId |
204 |
Notes
InboxNote is { body, authorUserId? } on a thread. Internal by definition: visible to agents, never
sent to the customer. There is no path from a note to an adapter — it is not a message with a
suppressed delivery, it is a different kind of row.
authorUserId null means the note came from an AI agent or a machine key, the same convention as
labels.
| Method | Path | Status |
|---|---|---|
| GET | /v1/threads/:threadId/notes |
200 |
| POST | /v1/threads/:threadId/notes |
201 |
| DELETE | /v1/threads/:threadId/notes/:noteId |
204 |
Canned replies
InboxCannedReply is { title, body, shortcut? }, unique on (tenantId, title). The shortcut is what
an agent types to insert it; it is not validated for uniqueness.
| Method | Path | Status |
|---|---|---|
| GET | /v1/canned-replies |
200 |
| POST | /v1/canned-replies |
201 |
| PATCH | /v1/canned-replies/:id |
200 |
| DELETE | /v1/canned-replies/:id |
204 |
Inserting a canned reply is a client-side concern — you fetch the body and send it through the normal reply path. There is no send-canned endpoint.
Bulk import (cutover only)
Two routes are @RequireScopes('admin') rather than inbox, because they write history the caller
asserts happened — a whole workspace taxonomy plus its thread and ticket attachments — which no
ordinary inbox-capable key should be able to backdate:
POST /v1/inbox/import/labels → 200
POST /v1/inbox/import/canned-replies → 200
Both return 200 with per-row results rather than 201, and a totals object of
{ imported, already, skipped, failed }. Canned replies are idempotent by title. These live on the
annotations controller rather than the import controller so they reuse the same tenancy, idempotency
and shared-InboxLabel logic as the ordinary routes instead of reimplementing it.