# Threads and messages

A thread is one conversation with one of your end users; a message is one entry in it. Everything else in Support — statuses, labels, agents, attachments — exists to describe a thread or to ride on a message.

## Opening a thread

[`support_thread_create`](/reference/mcp-tools/support_thread_create) takes the conversation and nothing else:

```json
{
  "type": "ticket",
  "title": "Cannot upload my avatar",
  "authorExternalId": "user_8842",
  "authorDisplay": "Alex Fern",
  "metadata": { "plan": "team", "appVersion": "4.19.2" },
  "labelIds": []
}
```

`type` is the one classification Support itself understands. A `ticket` is an end user asking for something to be fixed; `feedback` is unsolicited input that nobody is waiting on an answer to. Nothing automates off the distinction — it exists so a queue can be split — and it cannot be changed once the thread exists.

`title` is a short subject, one to five hundred characters. It is what a specialist reads in the queue and what a notification quotes, so it is required even for `feedback`.

`metadata` is a free-form JSON object that belongs entirely to you: plan, app version, device, the id of the order the complaint is about. Support stores it and gives it back, and reads nothing in it. Threads filed by a feedback widget fill it by a convention of four keys — see [The four conventional keys](#the-four-conventional-keys) below.

`rating` is a field of the thread rather than a key inside `metadata`, which is what makes it filterable: an optional integer from 0 to 10, `null` when unset. See [The rating field](#the-rating-field).

`labelIds` puts tags on the thread as it opens. Every id must name a label of this product; one that does not is reported as not found and the thread is not created. At most fifty ids.

Two things you cannot pass. There is no status argument — a thread always opens on the product's default open status, the non-archived `open`-category status with the lowest display order. And there is no priority argument: a new thread is `normal`, and moving it to `low`, `high` or `urgent` is an edit.

## The external author

`authorExternalId` is the whole of what Support knows about the person who wrote in. It is an opaque identifier from your own user base — a row id, a customer number, whatever you already use — and it is stored byte for byte as it arrived. Support does not resolve it, does not normalise it, does not check that it looks like anything, and calls nobody to enrich it. There is no end-user directory here and no login for an end user to have.

`authorDisplay` is the optional name you already know that person by, stored the same way. It is what a specialist sees at the top of the thread and what a new-thread notification says "From …" with. There is no field for an email address, a phone number or any other contact detail, and that is deliberate: Support never contacts your end users, so it has no use for a way to reach them. Reaching them is your product's job — see [Notifications](/ship/support/notifications).

Because the author is an identifier you supply, a thread is opened by your backend, never by an end user's browser: there is no unauthenticated route to open one on. Filtering the queue by `authorExternalId` matches verbatim, which is what makes "every thread this customer ever opened" a single call.

## Editing a thread

[`support_thread_update`](/reference/mcp-tools/support_thread_update) covers the thread's own fields — `title`, `priority`, `labelIds` and `metadata` — and at least one of them must be present.

`labelIds` and `metadata` both replace rather than merge. Passing three label ids leaves the thread carrying exactly those three; passing `[]` strips every label. Passing a metadata object replaces the whole object. That makes the call idempotent and leaves no window for two callers doing read-modify-write over the same thread to lose each other's work.

Two things this tool deliberately cannot do. Moving the thread to another status is [`support_thread_status_set`](/reference/mcp-tools/support_thread_status_set), and putting an agent on it is [`support_thread_assign`](/reference/mcp-tools/support_thread_assign) — each carries rules of its own (an archived status is refused, a deactivated agent is refused), and a second way in would be a second place to forget them.

## Reading threads back

[`support_thread_get`](/reference/mcp-tools/support_thread_get) returns one thread with its status joined in. A thread that belongs to another product is reported as not found, never as forbidden.

[`support_thread_list`](/reference/mcp-tools/support_thread_list) is the queue, and every filter lives on it: `type`, `statusId`, status `category`, `assignedAgentId`, `authorExternalId`, the `createdFrom` / `createdTo` window, the `ratingMin` / `ratingMax` bounds, and `labelIds`. Filters combine by narrowing, `labelIds` included — asking for two labels returns the threads carrying both, not either.

Pages are newest first, up to 100 threads at a time and 25 by default, walked with `offset`. `sort` chooses the column: `created` is newest by creation time, `updated` is most recently modified first, which is the order a live queue wants.

Every thread comes back with its status object, its labels by name, its `assignedAgentId` or `null`, its `rating` or `null`, your `metadata`, and `lastReplyAt` — the time of the last **public** message, or `null` while there are none. Internal notes do not move it, on purpose: the field answers "when did this person last hear something", so a note to a colleague must not make a neglected thread look answered.

## Messages

[`support_message_create`](/reference/mcp-tools/support_message_create) posts one entry:

```json
{
  "threadId": "b50f26b4…",
  "direction": "outbound",
  "authorAgentId": "13c893aa…",
  "body": "Thanks for the report. Which browser are you on?",
  "visibility": "public"
}
```

A message has three independent axes and one hard rule.

**Direction** is where it came from: `inbound` from the end user, `outbound` from support.

**Visibility** is who may see it. `public` is part of the conversation the end user sees; `internal` is a note between specialists that never leaves the support contour. It defaults to `public` when omitted, so an internal note is always a deliberate act. Note that "the end user sees it" describes your own product's rendering: Support shows nobody anything, it only tells you which messages are showable.

**Authorship** is the hard rule. Exactly one of `authorExternalId` and `authorAgentId` is set — never both, never neither — and the two are different universes of identifier: the first is your opaque external id, the second is a support agent of this product. A message naming an agent of another product is reported as not found. The rule is enforced both at the input and as a constraint in the database.

The three axes do not derive from one another. An internal note may be inbound as well as outbound — "the customer called and said X" is a real thing to record — and every combination goes in exactly as given.

`body` is stored verbatim, one to fifty thousand characters, with no trimming and no normalisation.

Posting a public message stamps the thread's `lastReplyAt`, and a public inbound message can also move the thread's status on its own — see [Statuses](/ship/support/statuses). `attachmentIds` names files the message carries; see [Labels and attachments](/ship/support/labels-and-attachments).

## Reading a conversation

[`support_message_list`](/reference/mcp-tools/support_message_list) reads forward — oldest first, because that is how a conversation is read, and unlike the thread queue, which is newest first.

Naming a `threadId` gives you that conversation. `visibility: "public"` returns what your end user is allowed to see, which is the filter your own product's support screen should send; omitting `visibility` returns public messages and internal notes together, which is what a specialist wants. `direction` narrows the same way.

Each message comes back with its two author fields — one filled, one `null` — its direction, its visibility, its creation timestamp, and its attachments, each with a freshly signed download URL. Attachments whose upload was never confirmed are never listed.

## Catching up across the whole product

Omit `threadId` and the same tool answers a different question: every message of this product, in one stream. It is the read a client runs after its own consumer was down and it needs to find out what it missed.

Pass `since` with the `createdAt` of the last message you already have — strictly after, so that message does not come back a second time — and then follow `nextCursor`:

```json
{ "since": "2026-08-19T17:11:32.344Z", "limit": 100 }
```

The response carries `nextCursor`, an opaque position. Pass it back as `cursor` to get the next page, and repeat until it comes back `null`, which means you have caught up. The position names both the timestamp and the message, so a walk neither skips nor repeats a message even when several were posted in the same instant. Never construct a cursor yourself.

`offset` still works and is still the older, weaker option: rows arriving mid-walk shift every later page, so an offset walk can skip a message. The two paginations cannot be combined — sending `cursor` and `offset` together is refused rather than silently resolved.

The product-wide form is the thread-scoped form with one filter removed. It reads exactly the same rows under exactly the same product boundary, so it can never surface a message the thread-scoped read would have hidden.

## Feedback threads

A `feedback` thread is the same thread object as a `ticket`. There is no second type of record, no separate table and no separate route: what follows is the vocabulary for filling the thread you already know, so that a rating widget in your frontend and a dashboard reading the queue three months later agree on what a thread means.

Two of those things are different in kind. The context keys are a **convention** inside the free-form `metadata` object — Support stores them and never looks at them. The `rating` is a **field of the thread**, which Support validates and can filter on.

`title` is required for a `feedback` thread exactly as it is for a ticket. A widget that collects only a score still has to compose one; "Rated the checkout step 8/10" is a better title than the score alone. Everything else behaves as above: a feedback thread opens on the product's default open status, at `normal` priority, unassigned, and it can be labelled, assigned and answered like any other.

> **NOTE**
> `rating` is available from Support 0.6.0. The metadata conventions below are a convention rather than a feature and work on any version, since Support never reads `metadata` at all.

### The four conventional keys

| Key | What it holds |
|---|---|
| `pageUrl` | The address the end user was on when they wrote. The whole URL, as the browser had it. |
| `elementRef` | Your own stable name for the thing they were rating — a screen, a step, a control. Not a CSS selector or a DOM path, both of which change when the markup does. |
| `device` | Whatever you already know about the client: model, operating system, browser. One human-readable string. |
| `locale` | The language the end user was reading in, as a BCP 47 tag — `en-GB`, `pt-BR`. |

**This is a convention, not a schema.** Support does not validate these keys. It does not reject a thread that omits all four, does not reject one that adds ten keys of its own, does not check that `pageUrl` is a URL or that `locale` is a real tag, and does not normalise, trim or lowercase any value. A `pageUrl` of `"not a url"` is stored as the string `"not a url"` and comes back as that string.

So the convention buys you one thing, and it is worth having: every consumer that reads your queue — your own dashboard, an export, a colleague writing a one-off query — finds the same information under the same names. Nothing enforces it, which means it holds only as long as the code that writes it agrees with the code that reads it.

Note the consequence of `metadata` being opaque. You cannot filter the thread queue by `pageUrl`, or ask for every thread from one locale; there is no query parameter for a metadata key and there is no index behind one. Metadata is carried, not searched. Anything you need to slice the queue by has to be a field of the thread, which is precisely why the score is one.

### The rating field

`rating` is an optional integer from 0 to 10 inclusive. It is `null` when unset, and `null` is what a thread that was never scored reads back as.

It is accepted on thread create on both planes — `support_thread_create` on the authenticated side, and public [`POST /threads`](/ship/support/public-api#post-threads) — and it reads back on every route that returns a thread.

It is **create-only**. `support_thread_update` does not accept `rating`, and there is no other route that changes one: a score is what the end user said at the moment they said it, and a thread's rating is fixed when the thread opens. Collect the score before you open the thread rather than opening one and scoring it afterwards.

The range is closed and the type is exact. `11`, `-1`, `7.5` and `"8"` are each a `400` of the [validation shape](/ship/support/public-api#authentication): a map from field name to what is wrong with that field, with no `message` at the top level. A string that happens to contain a number is not coerced — send `8`, not `"8"`.

Zero is a score, not an absence. `0` is the lowest rating an end user can give and it is stored and filtered as such; the way to say "no score" is to omit the field, which leaves it `null`. Code that treats a falsy rating as missing will silently discard every worst-possible review it is given, which is the one class of feedback nobody can afford to lose.

Support attaches no meaning to the number. It does not decide that 9 and 10 are promoters, it does not compute an average, and it does not move a thread's status or priority because the score was low. The scale is yours: a five-emoji widget mapping to 0, 2, 5, 8, 10 and an eleven-point NPS question both fit in the same field, and Support cannot tell them apart. Whichever you choose, keep it stable — a thread stores the number, never the scale it came from, so re-mapping your widget silently rewrites the meaning of every thread already collected.

### Filtering by rating

The thread list takes two bounds, on both planes:

| Parameter | Rules |
|---|---|
| `ratingMin` | Integer 0–10. Only threads whose `rating` is at or above it. |
| `ratingMax` | Integer 0–10. Only threads whose `rating` is at or below it. |

Both bounds are inclusive. Either works alone, and together they are a window: `ratingMin=0&ratingMax=6` is every thread scored six or worse. They narrow the queue like every other filter and combine with the rest of them, so "unassigned feedback rated under seven this week" is one call.

**An unrated thread matches neither bound.** A thread whose `rating` is `null` is not above `0` and not below `10` — it is outside the comparison altogether. So `ratingMin=0` does not mean "everything"; it means "everything that was scored", and it silently excludes every ticket anyone opened without a score. To count your unrated threads, ask for the queue without either bound and subtract — there is no filter that selects for the absence of a rating.

### A frontend files an emoji rating

The end user taps an emoji on your checkout page. What reaches Support is a `feedback` thread carrying the score as `rating` and the circumstances as `metadata`.

The browser sends the tap to your own backend, and only to your own backend:

```javascript
await fetch('/api/feedback', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    score: 8,
    step: 'checkout.payment.submit',
    pageUrl: window.location.href,
    locale: navigator.language,
  }),
})
```

Your backend is what holds the product public key, and it is what calls the public edge:

```bash
curl -X POST "https://public.lessly.com/{productId}/support/threads" \
  -H "Authorization: Bearer lpk_…" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "feedback",
    "title": "Rated the checkout step 8/10",
    "authorExternalId": "user_8842",
    "authorDisplay": "Alex Fern",
    "rating": 8,
    "metadata": {
      "pageUrl": "https://shop.example.com/checkout/payment",
      "elementRef": "checkout.payment.submit",
      "device": "iPhone 15 Pro / iOS 18.2 / Safari",
      "locale": "en-GB"
    }
  }'
```

The key never goes to the browser, and the two-step shape above is not ceremony. A product public key read out of a page's network tab opens threads, reads your whole queue and reads your specialists' internal notes, and the public edge accepts browser requests only from Lessly's own origins — your application's origin is deliberately not on that list, so the direct call would fail even if you tried it. See [CORS](/ship/support/public-api#cors).

`authorExternalId` is your identifier for the person, taken from the session your backend already authenticated. It is opaque to Support, stored byte for byte, and never resolved or validated — which is the other reason this call belongs on your side, since a browser could claim to be anyone.

`metadata` comes back exactly as it was sent — same keys, same order, same values, nothing added and nothing dropped. That is the whole contract for it. The thread is now in the queue like any other: a specialist can label it, assign it and answer it, and nothing about having a `rating` keeps it out of the inbox.

## Where to go next

- [Statuses](/ship/support/statuses): the statuses a thread moves through.
- [Agents](/ship/support/agents): the agents who write outbound messages.
- [Surveys](/ship/support/surveys): asking for feedback rather than waiting for it.
- [Tools, SDK and errors](/ship/support/tools-and-errors): every tool named here, and how it is called.
