Handle comments and messages
List, reply to, and moderate comments, and read and send direct messages, with per-platform examples for X, Bluesky, Threads, YouTube, and more.
The normalized facades use platform references and opaque cursors. The examples
assume one social client built with the platform’s adapter, an account from
connectedAccountRef, and a post from platformPostRef.
Comments
Comment and message items expose id, authorId, authorHandle, authorName,
text, createdAt, and parentId when the provider supplies them. Provider
fields remain available, and native retains the provider record used for the
projection. Missing fields stay absent rather than becoming invented values.
Use id as the comment identity across platforms. Bluesky exposes the reply’s
AT-URI there; LinkedIn exposes the full comment URN needed for replies. Its raw
numeric ID remains in native.id.
const comments = await social.comments.list(post, { limit: 20 });
const commentId = comments.items[0]?.id;
if (commentId !== undefined) {
await social.comments.reply({ ...post, kind: "comment", commentId }, { text: "Thanks." });
}
comments.list(post, { cursor, limit }) returns { items, nextCursor? }. Pass
nextCursor back unchanged. comments.reply takes a comment reference: the
post reference fields plus kind: "comment" and the provider commentId.
Pagination is route-specific:
- X, Instagram, YouTube, and LinkedIn accept
limitand return a cursor when more pages exist. X takes 10 through 100, Instagram 1 through 50, and YouTube and LinkedIn 1 through 100. Instagram, YouTube, and LinkedIn default to 25. - Threads ignores
limitand returns Meta’s default page size with anaftercursor. - Bluesky returns only the direct replies in the post’s thread view, never
returns a cursor, and rejects a
cursorwithunsupported_capability.
comments.iterate follows only the nextCursor values the route returns. It
does not walk nested replies, so on Bluesky it yields one page of direct
replies.
Messages
const conversations = await social.messages.listConversations(account, { limit: 20 });
messages.listConversations(account, { cursor, limit }) returns
{ items, nextCursor? }. listMessages takes a conversation reference, and
messages.send(ref, { text }) writes to it. messages.iterateConversations and
messages.iterateMessages follow pages.
Normalized messages run on two kinds of route:
- The direct X adapter.
- The direct Instagram adapter with Instagram Login and messaging permission.
- The managed Zernio backend for X, Bluesky, Instagram, and Facebook accounts connected through Zernio. Recipient permissions and reply windows still apply upstream.
The direct Bluesky adapter exposes chat only through its native module, shown
below. Calling social.messages on a direct Bluesky account raises
unsupported_capability.
Read an inbox across accounts
inbox.list reads a conversation page for each supplied account and a comment
page for each supplied post. Supply up to 50 sources across backends. Enable it
with inboxAggregation() from @opencoredev/social-sdk/inbox, so clients that
never aggregate an inbox do not bundle it:
import { createSocial } from "@opencoredev/social-sdk";
import { inboxAggregation } from "@opencoredev/social-sdk/inbox";
const social = createSocial({ backends, inbox: inboxAggregation() });
const inbox = await social.inbox.list({ accounts: [account], posts: [post], limit: 20 });
const records = inbox.entries.map((entry) => entry.record);
Each entry carries its source account or post and a normalized record.
sources reports each source’s read, exhausted, or failed state. A failed source
does not discard successful reads. Every source is authorized on every call,
including sources that finished on an earlier page.
limit applies to each source, so the combined page can contain more items.
Entries stay grouped by source and are never truncated or sorted by timestamp.
Pass the returned nextCursor with the same sources, order, limit, and tenant
to continue; exhausted sources stay exhausted, while failed sources retry from
their previous position. Comment aggregation requires post references because
providers do not expose a common account-wide comments endpoint.
The aggregate cursor retains bounded provider cursors for up to 50 sources. Store it as an opaque value; it can be larger than a single-account cursor.
Platform support
Native examples in the tabs assume a client built with that platform’s adapter
under the "default" key, a guarded native module, and an operation context:
import type { AdapterOperationContext } from "@opencoredev/social-sdk";
const native = social.native("default", { acknowledgeUnsafe: true });
if (native === undefined) throw new Error("This adapter has no native module");
const context: AdapterOperationContext = {
backendInstance: "default",
correlationId: crypto.randomUUID(),
retryBudget: { maxAttempts: 1, maxElapsedMs: 30_000 },
};
X
Comments list and reply. Messages: conversations, messages, and send.
Access: user token. Listing replies needs tweet.read and users.read. DM
reads need dm.read, users.read, and tweet.read; writes also need
dm.write.
comments.list runs X recent search for conversation_id:<postId>, so it only
returns replies from the last 7 days, includes nested replies, and accepts a
limit from 10 through 100. Pass the conversation’s root post.
Configure x({ auth: { userId, accessToken } }). Normalized DMs need a user
token; an app bearer token is rejected with missing_permission.
listConversations derives one latest event per conversation from /2/dm_events,
never repeats a conversation across pages, and stops after 1,200 conversations. X
keeps only 30 days of DM events, so it is not a complete historical inbox.
Native sendDirectMessage, sendConversationMessage, and
createGroupConversation are available from the acknowledged X native adapter.
A reply on X is a post, so delete one with native deletePost and the reply’s
post ID. It calls DELETE /2/tweets/{id}. X only lets the authenticated user
delete their own posts, so you cannot remove someone else’s reply.
const conversations = await social.messages.listConversations(account, { limit: 20 });
await native.sendDirectMessage({ account, participantId, text: "Hello.", context });
await native.deletePost({ account, postId: replyPostId, context });Native hideReply hides or unhides a reply in a conversation the authenticated
user started. It needs a user token with tweet.moderate.write, tweet.read, and
users.read, and returns the hidden state X reports.
await native.hideReply({ account, replyId, hidden: true, context });Bluesky
Comments list and reply. Messages: native chat methods only.
Access: an authenticated session. Chat needs a session that includes
transition:chat.bsky; see connect accounts.
Configure bluesky({ auth: { service, did, accessJwt } }). Comment items are
thread views with the reply’s AT-URI in normalized item.id and the original
view in item.native. Use item.id as the commentId when replying.
Chat runs through the native module, not social.messages. Each method takes
the acting account and returns the provider-shaped chat.bsky.convo response:
const convos = await native.listConversations({ account, limit: 20, context });
const messages = await native.listMessages({ account, conversationId, limit: 50, context });
await native.sendMessage({ account, conversationId, text: "Thanks for reaching out.", context });A reply on Bluesky is a post. Delete one with native deletePost and its uri
and cid. It only deletes posts in the authenticated account’s repository; a
URI from another DID raises unauthorized. To hide another account’s reply in
your own thread, use hideReply.
await native.deletePost({ account, post: { uri: replyUri, cid: replyCid }, context });Native hideReply hides or unhides a reply by adding its AT-URI to, or removing
it from, the hiddenReplies list on the root post’s threadgate. Only the root
post’s author can do this, so the call raises invalid_input for replies in
other accounts’ threads. The adapter creates a threadgate without reply rules
when none exists, and updates an existing one with swapRecord so a concurrent
change fails instead of being overwritten. Hidden replies stay in the thread;
Bluesky clients collapse them.
await native.hideReply({ account, replyUri, hidden: true, context });Threads
Comments list and reply. Messages: not supported. Listing needs
threads_basic and threads_read_replies. Replying needs threads_basic and
threads_content_publish. Hiding or managing replies needs threads_basic and
threads_manage_replies.
Configure threads({ auth: { userId, accessToken } }). Messaging is
unsupported-by-platform, so message calls raise unsupported_capability.
Moderation is native. hideReply hides or unhides a top-level reply to your
post; Meta hides its nested replies with it. listConversation,
listPendingReplies, and managePendingReply cover the rest.
Native deleteComment deletes a reply the authenticated user published, using
DELETE /{threads-media-id}. It needs threads_delete and counts toward Meta’s
limit of 100 deletions per 24 hours. Use hideReply for other users’ replies.
await native.hideReply({ account, replyId, hide: true, context });
await native.deleteComment({ account, commentId: replyMediaId, context });YouTube
Comments list and reply. Messages: not supported. Access: an OAuth
token for the channel. Replies, moderation, and deletion all need
https://www.googleapis.com/auth/youtube.force-ssl, which the default
connection scopes do not include.
Configure youtube({ auth: { accessToken, channelId } }) for the authorized
channel. Native heldComments lists comment threads held for review. Each held
thread’s comment ID is snippet.topLevelComment.id, not the thread id.
Native commentsModeration supports setModerationStatus, update, and
delete. setModerationStatus must be called by the owner of the channel or
video and requires a moderationStatus of published, heldForReview, or
rejected; omitting it raises invalid_input. banAuthor: true is valid only
with rejected.
Native deleteComment calls comments.delete. Google’s reference does not
say which comments a channel may delete; it documents a 403 forbidden for
insufficient permissions, which raises missing_permission. To remove another
user’s comment from your video, set its moderation status to rejected.
const held = await native.heldComments({ maxResults: 20, context });
await native.commentsModeration({
action: "setModerationStatus",
commentId, // snippet.topLevelComment.id from a held thread
moderationStatus: "rejected",
banAuthor: true,
context,
});Source: comments.setModerationStatus, accessed 2026-10-05.
Comments list and reply. Messages: conversation and message reads, plus text replies with Instagram Login. Access: a professional account with the matching comment-management or messaging permission.
Configure instagram({ auth: { accessToken, accountId, flavor } }). flavor
defaults to instagram-login; use facebook-login where the adapter requires
Facebook Login. Direct messaging requires Instagram Login. Conversation reads
include participants, and message reads include bodies, senders, recipients,
timestamps, and attachments. messages.send looks up the conversation
participants and sends only when it finds one other recipient. Facebook Login
messaging requires the separate Messenger Platform and a Page token. Zernio
also exposes Instagram messages as a managed backend.
Native moderation covers hiding and deleting comments, listing replies to one comment, and turning comments on or off for a media object:
await native.moderateComment({ account, commentId, hidden: true, context });
await native.deleteComment({ account, commentId, context });
const replies = await native.listCommentReplies({ account, commentId, limit: 25, context });
await native.setCommentsEnabled({ account, mediaId, enabled: false, context });Comments list and reply. Messages: not supported. Member comments need
r_member_social_feed for reads and w_member_social_feed for replies.
Organization comments need r_organization_social_feed and
w_organization_social_feed, respectively. These permissions require Community
Management approval. The adapter also reads the parent post before comment
operations: grant r_member_social for member authors or r_organization_social
for organization authors for that separate preflight.
Configure linkedin({ auth: { accessToken, author }, apiVersion }). author is
a urn:li:person:<id> or urn:li:organization:<id>; organization actions need
administrator access.
LinkedIn comments use Social Actions. Reply with the normalized id, which contains the full comment URN
(urn:li:comment:(urn:li:activity:…,…)); any other value
raises invalid_input. If a write is accepted without a returned URN, the SDK
can raise ambiguous_outcome. LinkedIn messages raise
unsupported_capability. Hiding comments is unsupported-by-platform: the
Comments API has no hide or moderation-status operation.
const comments = await social.comments.list(post, { limit: 20 });
const commentUrn = comments.items[0]?.id;
if (typeof commentUrn === "string") {
await social.comments.reply(
{ ...post, kind: "comment", commentId: commentUrn },
{ text: "Thanks." },
);
}Native deleteComment takes the share or UGC post URN and the full comment
URN, and calls DELETE /rest/socialActions/{postUrn}/comments/{commentId}. The
SDK adds the actor query when the author is an organization. LinkedIn does not
document which comments an actor may delete.
await native.deleteComment({ account, postId: "urn:li:share:123", commentId, context });Errors and native access
An unavailable operation raises unsupported_capability, not an empty page.
Provider permission or token failures use missing_permission; malformed
references and missing YouTube moderation status use invalid_input.
ambiguous_outcome means a write may have succeeded and must be reconciled.
social.native(backend, { acknowledgeUnsafe: true }) returns the native
handle used above. It bypasses tenant authorization and concurrency middleware,
so wrap native calls in your own policy.