# Read a Message Thread

Read one thread in full: the parent message and every reply, oldest first, with who replied last.

Source: https://www.opentrain.ai/docs/developers/api-reference/messages/thread/

Endpoint: GET /api/public/v1/messages/thread

Reads one message thread: the parent message and its replies, oldest first, with complete bodies. Get a `threadId` from `thread.threadId` on a message in [`GET /messages/audit`](https://www.opentrain.ai/docs/developers/api-reference/messages/audit/), or from `threadId` on a reply.

This endpoint is read-only. Attachments appear as opaque metadata only; to download one, pass its `attachments[].attachmentId` to [`GET /messages/{messageId}/attachments/{attachmentId}`](https://www.opentrain.ai/docs/developers/api-reference/messages/get-attachment/), and read [privacy and sensitive files](https://www.opentrain.ai/docs/developers/api-reference/messages/get-attachment/#privacy-and-sensitive-files) first.

**Requirements:** `messages:read` scope, and either membership in the conversation or employer access to its job. If you were removed from the conversation, or the conversation is hidden from you, the read is refused; employer job access does not override that.

## Paging and freshness

- Follow `nextCursor` until it is `null` to read every reply.
- To check for new replies later, save the ID of the last reply you read and pass it as `cursor`. This returns later replies only; it does not show edits to replies you already read.
- `freshness.version` changes when anything in the thread changes, including edits and deletions outside the page you read. Before you reply, read the thread again from the first page and compare `version`. It is an observation, not a lock: someone can still post between your read and your reply.

## Request

#### threadId (string, required)

The thread to read.

#### limit (number, default: 100)

Replies per page, 1–200.

#### cursor (string)

`nextCursor` from the previous page, or the ID of the last reply you read. It must be a reply in this thread.

## Response

#### asOf (string)

ISO time of the read. The page and `freshness` come from the same database snapshot.

#### threadId (string)

The thread.

#### conversationId (string)

The conversation the thread belongs to.

#### contextType (string)

`proposal_dm`, `job_dm`, or `channel`.

#### jobId (string | null)

Linked job.

#### proposalId (string | null)

Linked proposal, for proposal conversations.

#### needsEmployerReply (boolean)

`true` when the latest person-written message in the whole thread, parent included, came from someone outside your organization. System events, deleted messages and moderated messages are skipped, so they never open or answer it. A reply from any teammate answers it. See [How `needsEmployerReply` works](https://www.opentrain.ai/docs/developers/api-reference/messages/audit/#how-needsemployerreply-works).

#### parent (object | null)

The message that started the thread, as a message record (the same shape as `messages[]` in [`GET /messages/audit`](https://www.opentrain.ai/docs/developers/api-reference/messages/audit/)).

#### replies (object\[])

This page of replies, oldest first, as message records. Each record has `state` (`visible`, `deleted`, or `moderated`), `content` (`null` when deleted or moderated), `sender` (`{userId, displayName, role, employerOrganizationMember, isFromViewer}`), and opaque `attachments[]`.

#### replyCount (number)

Total replies in the thread.

#### nextCursor (string | null)

Pass back as `cursor` for the next page; `null` at the end.

#### latestWorkerReply (object | null)

`{messageId, senderUserId, createdAt}` of the latest reply from a freelancer in the whole thread.

#### latestEmployerReply (object | null)

`{messageId, senderUserId, createdAt}` of the latest reply from your organization in the whole thread.

#### freshness (object)

`{latestReplyId, latestReplyAt, version}`. See [Paging and freshness](#paging-and-freshness).

## Errors

| Status | `code` | Meaning |
| --- | --- | --- |
| `400` | `BAD_REQUEST` | Invalid query parameters, or a `cursor` that is not a reply in this thread (`details.reason: "UNKNOWN_CURSOR"`) |
| `401` | `UNAUTHORIZED` | Missing or invalid token |
| `403` | `FORBIDDEN` | Missing `messages:read` scope, or no access to the conversation. `details.reason` is `not_a_participant`, `membership_removed`, or `conversation_hidden` |
| `404` | `NOT_FOUND` | No such thread (`details.reason: "thread_missing"`) |
| `503` | `SERVICE_UNAVAILABLE` | A database dependency was briefly unavailable. Retry the same request after `Retry-After` |

## CLI, MCP, and SDK

- **CLI:** `opentrain messages thread get --thread-id <THREAD_ID>` with `--cursor` and `--limit`.
- **MCP:** `opentrain_get_message_thread`, with `threadId`, `cursor`, and `limit`.
- **TypeScript SDK:** `opentrain.getMessageThread({ threadId, cursor, limit })`.

```bash curl
curl -sS "https://app.opentrain.ai/api/public/v1/messages/thread?threadId=<THREAD_ID>" \
  -H "Authorization: Bearer $OT_API_TOKEN"
```

```bash CLI
opentrain messages thread get --thread-id <THREAD_ID>
```

```json MCP: opentrain_get_message_thread
{
  "threadId": "<THREAD_ID>"
}
```

```json 200
{
  "asOf": "2026-06-12T09:00:00.000Z",
  "threadId": "<THREAD_ID>",
  "conversationId": "<CONVERSATION_ID>",
  "contextType": "channel",
  "jobId": "<JOB_ID>",
  "proposalId": null,
  "needsEmployerReply": false,
  "parent": {
    "id": "<PARENT_MESSAGE_ID>",
    "conversationId": "<CONVERSATION_ID>",
    "contextType": "channel",
    "threadId": null,
    "createdAt": "2026-06-12T08:00:00.000Z",
    "updatedAt": "2026-06-12T08:00:00.000Z",
    "editedAt": null,
    "state": "visible",
    "content": "Question about the labeling guide, section 3.",
    "messageType": null,
    "system": null,
    "sender": {
      "userId": "<FREELANCER_ID>",
      "displayName": "Sam R.",
      "role": "freelancer",
      "employerOrganizationMember": false,
      "isFromViewer": false
    },
    "attachments": [],
    "hasAudio": false,
    "reactionCount": 0,
    "thread": { "threadId": "<THREAD_ID>", "replyCount": 1, "lastReplyAt": "2026-06-12T08:20:00.000Z", "needsEmployerReply": false }
  },
  "replies": [
    {
      "id": "<REPLY_ID>",
      "conversationId": "<CONVERSATION_ID>",
      "contextType": "channel",
      "threadId": "<THREAD_ID>",
      "createdAt": "2026-06-12T08:20:00.000Z",
      "updatedAt": "2026-06-12T08:20:00.000Z",
      "editedAt": null,
      "state": "visible",
      "content": "Use the stricter rule from section 3.2.",
      "messageType": null,
      "system": null,
      "sender": {
        "userId": "<EMPLOYER_USER_ID>",
        "displayName": "Alex P.",
        "role": "employer_owner",
        "employerOrganizationMember": true,
        "isFromViewer": true
      },
      "attachments": [],
      "hasAudio": false,
      "reactionCount": 0,
      "thread": null
    }
  ],
  "replyCount": 1,
  "nextCursor": null,
  "latestWorkerReply": null,
  "latestEmployerReply": { "messageId": "<REPLY_ID>", "senderUserId": "<EMPLOYER_USER_ID>", "createdAt": "2026-06-12T08:20:00.000Z" },
  "freshness": { "latestReplyId": "<REPLY_ID>", "latestReplyAt": "2026-06-12T08:20:00.000Z", "version": "<VERSION>" }
}
```
