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.

Related