> For AI agents: the complete Sanity documentation index is available at [https://www.sanity.io/docs/llms.txt](https://www.sanity.io/docs/llms.txt).

# Threads and comments

How comment threads are shaped: one field of one document, a first comment plus replies, and no separate thread record.

A thread hangs off one field of one document and holds the comment that started it plus its replies. There is no separate thread record to create: writing the first comment on a field creates the thread, and the SDK returns the two together. Everything else on a comments surface is a consequence of that shape.

## Threads

A thread is what you render one card for. It is what `useCommentThreads` gives you, already assembled into parent, replies, count, and status.

```typescript
const thread = {
  threadId: 'thread-9f2c',
  // The field the thread hangs off: 'title', a full path such as
  // 'body[_key=="c1"].content', or '' for a thread on the whole document.
  fieldPath: 'title',
  // The comment that started it.
  parentComment: {
    /* ... */
  },
  // Oldest first.
  replies: [
    {
      /* ... */
    },
  ],
  // The parent plus its replies.
  commentsCount: 3,
  // Resolving the parent resolves the thread.
  status: 'open',
  lastActivityAt: '2026-08-30T09:12:44.000Z',
}
```

`fieldPath` is the grouping key everywhere: `CommentList` groups by it, `CommentBadge` shows the count you compute by grouping on it, and a new comment names one when it is created.

## Comments

A comment carries its author, its message, and the state the UI renders from.

```typescript
const comment = {
  id: 'comment-4a71',
  createdAt: '2026-08-30T09:04:11.000Z',
  // Absent when the author is recorded on the server rather than the comment.
  authorId: 'user-abc',
  // Portable Text.
  message: [
    {
      /* ... */
    },
  ],
  threadId: 'thread-9f2c',
  // Absent on the comment that starts a thread.
  parentCommentId: 'comment-1f03',
  status: 'open',
  // Set once the message has been rewritten.
  lastEditedAt: '2026-08-30T09:31:02.000Z',
  // Always the published ID, shared by the draft and every version.
  documentId: 'post-123',
  // The exact ID it was written against: published, draft, or version.
  sourceDocumentId: 'drafts.post-123',
  fieldPath: 'title',
  // Inline comments only: the Portable Text the selection covered when the
  // comment was written.
  contentSnapshot: [
    {
      /* ... */
    },
  ],
  reactions: [{shortName: ':+1:', userId: 'user-def', addedAt: '...'}],
  // Local only: set when a create fails, and while it is retried.
  state: undefined,
}
```

Two fields are worth knowing about before you render anything.

**contentSnapshot** is the text an inline comment was written about, as it read at the time, which is what lets a comment about since-edited words still make sense. `CommentContentSnapshot` renders it as a quote, and renders nothing when there is nothing to quote.

**state** is not on the wire. The SDK sets it when an optimistic create fails and while it is retried, and a `createError` state is what turns a comment card red and offers a retry. See [Comment data flow](https://www.sanity.io/docs/collaboration/data-flow).

A comment on a run of text inside a Portable Text field carries one more: **selection**, the words it is about, in a form that survives edits to the text around them. It is what draws a highlight in an editor. For how it works, see [Inline comments](https://www.sanity.io/docs/collaboration/inline-comments).

## Messages are Portable Text

A comment's `message` is an array of Portable Text blocks, and a mention is an inline object inside one of them:

```typescript
const message = [
  {
    _type: 'block',
    _key: 'b1',
    children: [
      {_type: 'span', _key: 's1', text: 'Should this say '},
      {_type: 'mention', _key: 'm1', userId: 'user-def'},
      {_type: 'span', _key: 's2', text: "'s name instead?"},
    ],
  },
]
```

Two consequences you will meet.

A mention carries a `userId` and nothing else, so rendering a name means looking the user up. That is what [the provider's lookup](https://www.sanity.io/docs/collaboration/users-and-identity) is for.

A GROQ filter for "mentions me" has to descend into `children`, because mentions are not blocks. Pass it as the `filter` option of `useCommentsQuery`, which adds `_type == "sanity.comment"` for you and runs it over every comment the organization can read (see [Query comments](https://www.sanity.io/docs/collaboration/sdk-query-comments)):

```groq
count(message[].children[_type == "mention" && userId == $userId]) > 0
```

`CommentMessageContent` renders a message, mentions included. For a label or a hover title, [toPlainText](https://collaboration-platform.sanity.dev/?path=/docs/helpers--docs#toplaintext) flattens one to a string with mentions as `@Name` when you pass the user lookup.

## Which document, and which release

Reads and writes take a `DocumentHandle`: a document ID and type, plus an optional perspective. Project and dataset come from the surrounding `SanityApp` or `ResourceProvider`, so the handle is usually as short as it looks:

```typescript
const handle = {documentId: 'post-123', documentType: 'post'}
```

A release perspective in the handle scopes comments to that release, so the same field can carry different threads in a draft and in a release. Every other perspective pools draft and published, which is why a comment written against the published document and one written against the draft turn up in the same list.

Pooling is a default rather than a rule. The read takes a `scope` option (default `'perspective'`), documented in full by the SDK: `'exact'` matches only the ID you passed, and `'all'` returns every comment on the document.

## Status

A comment is `'open'` or `'resolved'`. Resolving is a property of the thread rather than of each comment, so only the parent carries an action for it. `CommentThread` passes `onStatusChange` to the parent comment alone, and the default action list leaves the entry out for a reply.

Resolved is not deleted. A resolved thread still exists and still reads, and is filtered out of the default view. That is the case behind [onActiveCommentNotFound](https://www.sanity.io/docs/collaboration/navigation): a link to a resolved comment points at something an open-only panel is not showing.

## System comments

Some comments are authored by Sanity rather than by a person, and carry an author ID of `<system>`. That ID always resolves to "Sanity", with the Sanity logo as its avatar, whatever your user lookup returns, so your directory never has to know about it.

## Where comments live

Comments are stored per organization and reached through the collaboration API. Sanity Studio comments before Comments API v2 were stored per dataset. Nothing in your app's own dataset changes when a comment is written.

## Where to go next

- How the data reaches the components: [Comment data flow](https://www.sanity.io/docs/collaboration/data-flow).
- What renders a thread, and what renders one comment: [Comment components](https://www.sanity.io/docs/collaboration/components).
- Every type, field by field: [Types](https://collaboration-platform.sanity.dev/?path=/docs/types--docs).



## Related changelog entries

Entries are listed newest first; each link points to the full entry as markdown. Follow one when you need to know what changed, when, or why — for example, to summarize recent updates, explain behavior that differs from older documentation, or check whether a fix has shipped.

- [@sanity/collaboration v0.3.0: New send shortcut, Cancel that clears the draft, and a discard action for failed comments](https://www.sanity.io/docs/changelog/e3fbc3a3-2d35-40e6-9309-786ec3e24b49.md) — October 29, 2026