message_send, resolved against the forum channel, and moderating other members’ posts needs message_manage. See Manage a post.
Forum posts have no private variant. Following a post is a notification preference, the bell in the app, not an access list. Your bot hears every post in every forum it can view whether it follows the post or not.
Open a post and talk in it
createForumPost(serverId, forumChannelId, opts) takes the parent forum channel and an options object: title (1 to 100 characters after trimming), content (the post’s first message, non-blank), optional tagIds, optional mentions, and optionally an embed and components so the first message is a card. Title, body, and card are all encrypted under the server key together, so they never split across an epoch change.
It resolves with a ForumPost. The SDK waits a few seconds for the server’s own create event, which is where starterMessageId comes from. If that event does not arrive in time, the returned row is built from what the SDK sent and starterMessageId is null. The real row lands on forumPostUpdate whenever it arrives.
Sending into a post is the ordinary send() with the parent forum channel id plus { postId }. Every call that takes a location accepts postId in its options next to threadId: send(), sendCard(), editCard(), fetchMessages(), closePoll(), fetchPollState(), and fetchPollVoters(). A Message received inside a post already knows its container, so reply(), react(), edit(), delete(), pin(), and editCard() on it route into the post with no option at all. Passing both threadId and postId is a local error, because a message lives in one container.
Entering a post moves the cursor
Under the hood the SDK selects the server and enters the post. Unlike entering a thread, this is a cursor move: the server repoints the session’s text cursor at the post, so the ordinary message frame follows with no extra slot. A run of sends into one post enters once. The next action on a plain channel re-selects that channel. None of this needs your attention, but it is why a post action never takes agroupId.
Receive post messages
Post traffic arrives on the samemessageCreate firehose, gated on the parent forum’s visibility. A message inside a post has:
msg.reply() and msg.channel.send() inside a post route back into that post. Unlike a thread message, a post message never needs the SDK’s index to find its parent, so reply() never rejects for a missing parent here.
The starter arrives from the create event
A post’s first message never rides the ordinary message stream. It arrives with the post’s own create event, which the SDK turns into amessageCreate with starter: true, whose messageId equals ForumPost.starterMessageId. It is emitted once, right before the matching forumPostUpdate, and a redelivered create event never emits it twice.
msg.card like any other message.
Read a post’s history
fetchMessages() takes a postId and pages the post’s own messages, addressed the same way a send is: the parent forum channel id plus the post.
before, after, around, limit), and the same message_read_history permission applies. Rows come back with postId and forumChannelId set and channelId equal to the post, so reply(), react(), edit(), and editCard() on a fetched row act inside the post. See Message history.
Browse a forum
fetchForumPosts(serverId, forumChannelId, opts?) pages a forum’s posts, active first by default, with pinned posts at the front of the first page. Pass signal to cancel the wait.
Do not poll this to notice new posts. The server reads the whole forum for every page and prices the call as a heavy operation, and it pages backwards only: there is no “after” or “changed since” cursor.
forumPostUpdate already reaches your bot for every post in every forum it can view, on creation and on every later change, so subscribe to that and read a page only to backfill after a start.tag (one tag id), onlyMine, and onlyFollowing. before continues a page from the previous page’s oldest sort key: lastActiveAt for the activity sort, createdAt for the created sort. Rows carry starterContent (the decrypted first message) and following (the bot’s own follow state).
fetchForumMeta(serverId, forumChannelId) reads the forum’s settings: its tags (names are end-to-end encrypted, decrypted best-effort), guidelines, requireTag, defaultSort, autoArchiveDays, defaultReaction, and totalPosts. A forum created before it had a settings row answers defaults rather than an error.
moderated: true can only be applied by a member holding message_manage.
Manage a post
setForumPostState(serverId, forumChannelId, postId, field, value) takes one of five fields. 'archived', 'locked', and 'pinned' take a boolean. 'answered' takes the answer message (a Message with a createdAt) or null. 'move' takes the target forum channel id. Locking, pinning, and moving need message_manage. Archiving and answering also allow the post’s author, which your bot is for posts it created.
reactToStarter() is fire-and-forget on the wire. The result is an ordinary reactionUpdate, and a denial produces no frame at all. Inside a post, msg.react() on the starter message does the same thing.
followForumPost() is purely a notification preference. A firehose bot already receives every visible post’s replies either way.
There is no
create_posts or manage_posts permission name, and there never will be. The server’s create-posts capability bottoms out at message_send, resolved against the forum channel (which may override it per channel), so creating a post and sending inside one need message_send, and can('message_send', serverId) is the pre-check. Post moderation on other members’ posts (editForumPost(), setForumPostState()) needs message_manage. A refusal arrives as a CloakActionError from the call, -2 on createForumPost().Lifecycle events
forumPostUpdate fires with a decoded ForumPost on creation, on a reply bumping activity, on archived, locked, pinned, or answered flips, and on tag edits. It reaches every member who can view the parent forum, and it fires for your bot’s own posts too. forumPostDelete fires with { serverId, forumChannelId, postId } on deletion.
autoArchiveDays. A locked post rejects replies.
client.postParent(postId) returns the parent forum channel of a post the SDK has seen, or undefined. It is fed by every post row and every in-post message the bot receives, so it is a synchronous, best-effort lookup.
Deny codes
Asend() into a post that is locked answers -18 on sendRejected. A send into the forum channel itself answers -17. The post methods reject with a CloakActionError. The codes are listed under Forum posts on the errors page; the ones you are most likely to hit are -2 (creation denied for want of message_send on the forum, or a state change denied for want of message_manage), -4 (a tag violation: unknown tag, a moderated tag without message_manage, or a required tag missing), and -5 (the forum is at its post cap).
Next
Threads
The other container, and how it differs.
Sending messages
The send path posts ride, including
SendOptions.Forum helper bot
A complete bot that tags, answers, and locks posts.
Events
forumPostUpdate and forumPostDelete payloads.