The REST API
Everything the Antimatter apps do goes through the same HTTP API, so your scripts can do it too. This page explains how to authenticate, the resources you will use most, how responses and errors look, and how to follow events live over the WebSocket.
Basics
- Base URL:
https://chat.example.com/api/v4. All endpoints below are relative to it. - Format: JSON in and out, except file uploads (multipart). Send
Content-Type: application/json. - IDs: every object has a 26-character
id. Times are milliseconds since the Unix epoch (create_at,update_at,delete_at; 0 means "not deleted"). - Communities are teams: the API calls them
teams, with ateam_id. - Yourself: wherever a path takes a
user_id,memeans the signed-in user, as inGET /users/me.
The full list of endpoints is in the server's code: the routes are declared in server/channels/api4/*.go and the types in server/public/model. The OpenAPI description in the repository's api/v4/source folder covers the endpoints inherited from Mattermost, and some of Antimatter's additions: moving threads, model connections, agents, community rules, focus mode and message actions. The others are only in the code.
Authentication
Every request, except signing in and a few public endpoints, needs a token in the Authorization header:
curl -H "Authorization: Bearer $TOKEN" https://chat.example.com/api/v4/users/me
Three kinds of token are accepted there: personal access tokens, session tokens and OAuth access tokens. Authorization: Token … is accepted too. Tokens in the query string (?access_token=) are only accepted for OAuth access tokens.
Personal access tokens (recommended for scripts)
A personal access token acts as you, does not expire unless you give it an expiry date, and can be revoked at any time. Personal access tokens are off by default:
- A system admin turns on Enable Personal Access Tokens in System Console › Integrations › Integration Management (
ServiceSettings.EnableUserAccessTokens,AM_SERVICESETTINGS_ENABLEUSERACCESSTOKENS). - For each person who may create tokens, a system admin opens System Console › User Management › Users, chooses Manage roles in the person's menu and ticks Allow this account to generate personal access tokens. System admins can always create them.
- The person opens their avatar at the top right, then Profile & settings › Security. Under Personal access tokens, they describe what the token is for and choose Create token. The token is shown once: copy it.
With the API: POST /users/{user_id}/tokens with {"description": "backup script", "expires_at": 0} (expires_at in milliseconds; 0 for none). The response's token field is the secret; its id is what you use to revoke it (POST /users/tokens/revoke with {"token_id": "…"}). From the command line: amctl token generate <user> <description>.
A system admin can cap token lifetimes with Maximum Personal Access Token Lifetime (days) on the same System Console page.
Session tokens
Signing in with a password returns a session token in the Token response header, the same kind of session the apps use:
curl -i -X POST https://chat.example.com/api/v4/users/login \
-H 'Content-Type: application/json' \
-d '{"login_id": "maya", "password": "…"}'
Add "token": "123456" for an account with multi-factor authentication. Sessions expire as the server's session settings say, and the login endpoint is rate limited (5 requests per second). POST /users/logout ends the session.
X-CSRF-Token header for anything but GET; a token in the Authorization header needs no CSRF header.OAuth 2.0 apps
To let people sign in to your application with their Antimatter account, register an OAuth 2.0 app. Antimatter is then the OAuth provider:
- Enable OAuth 2.0 Service Provider must be on (System Console › Integrations › Integration Management,
ServiceSettings.EnableOAuthServiceProvider, on by default). - Someone with the right to manage OAuth apps (system admins by default) opens Integrations › OAuth 2.0 apps (community menu › Community settings › Integrations), and adds the app with its callback URLs. They get a client ID and a client secret.
- Your application sends people to
https://chat.example.com/oauth/authorizewithresponse_type=code,client_id,redirect_uriandstate. PKCE withcode_challenge_method=S256is supported. - It exchanges the code at
POST https://chat.example.com/oauth/access_token(grant_type=authorization_code, thenrefresh_tokento renew), and calls the API withAuthorization: Bearer <access token>.
People see the apps they authorised in Profile & settings › Security › Apps you authorized.
Main resources
| What | Endpoints |
|---|---|
| You and other people | GET /users/me, GET /users/{user_id}, GET /users/username/{username}, POST /users/ids (a list of IDs), POST /users/search, GET /users?in_team=…&in_channel=… |
| Communities | GET /users/me/teams, GET /teams/{team_id}, GET /teams/name/{name}, PUT /teams/{team_id}/patch, GET /teams/{team_id}/members, POST /teams/{team_id}/members |
| Channels | GET /users/me/teams/{team_id}/channels, GET /teams/name/{team}/channels/name/{channel}, POST /channels, POST /channels/direct (two user IDs), POST /channels/group, POST /channels/{channel_id}/members |
| Messages (posts) | POST /posts, GET /posts/{post_id}, PUT /posts/{post_id}/patch, DELETE /posts/{post_id}, GET /channels/{channel_id}/posts, GET /posts/{post_id}/thread, POST /teams/{team_id}/posts/search |
| Reactions | POST /reactions with {"user_id", "post_id", "emoji_name"}, DELETE /users/{user_id}/posts/{post_id}/reactions/{emoji_name} |
| Files | POST /files (multipart: channel_id and files), then put the returned IDs in the post's file_ids; GET /files/{file_id} |
| Preferences | GET /users/me/preferences, PUT /users/me/preferences |
| Integrations | /hooks/incoming, /hooks/outgoing, /commands, /bots, /oauth/apps (see the following pages) |
| Server | GET /system/ping (health, no token needed), GET /config/client?format=old (the settings clients see) |
Post a message
curl -X POST https://chat.example.com/api/v4/posts \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"channel_id": "'$CHANNEL_ID'", "message": "Backup finished: **42 GB** in 3 min"}'
Messages are Markdown. Add "root_id" (the first post of a thread) to reply in a thread, "file_ids" for files and "props" for attachments and other extras (see Attachments).
Find a channel's ID
curl -H "Authorization: Bearer $TOKEN" \
https://chat.example.com/api/v4/teams/name/lumen-labs/channels/name/ops-alerts
The names are those in the channel's link: /lumen-labs/channels/ops-alerts.
Pagination
List endpoints take page (from 0) and per_page (60 by default, at most 200). Keep asking for the next page until a page comes back shorter than per_page.
A channel's messages (GET /channels/{channel_id}/posts) also take since (a time: everything changed since then), or before and after (a post ID). They come back as a post list: {"order": [IDs, newest first], "posts": {ID: post}, "next_post_id", "prev_post_id"}.
Errors
Errors use the usual HTTP status codes (400 bad input, 401 not signed in, 403 not allowed, 404 not found, 501 feature turned off) and a JSON body:
{
"id": "api.context.permissions.app_error",
"message": "You do not have the appropriate permissions.",
"detailed_error": "",
"request_id": "jz8zm5nunpbt8f3gpqsz5dq6so",
"status_code": 403
}
Use id in your code: message is translated into the user's language. detailed_error is empty unless the server runs in developer mode (ServiceSettings.EnableDeveloper). Quote request_id to the server's admin: it is in the logs.
Rate limits
Rate limiting is off by default. When a system admin turns it on (System Console › Environment › Rate Limiting), each client gets 10 requests per second with bursts of 100 by default, counted by IP address or by user. Responses carry X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset; over the limit, the server answers 429 with a Retry-After header in seconds. Some endpoints, such as signing in, have their own limit whatever the setting.
Write scripts that wait and retry on 429, and avoid polling: follow the WebSocket instead.
The WebSocket event stream
Connect to wss://chat.example.com/api/v4/websocket. Authenticate either with the Authorization: Bearer header on the connection request, or by sending this as the first message:
{"seq": 1, "action": "authentication_challenge", "data": {"token": "…"}}
The server sends a hello event, then an event for everything you are allowed to see:
{
"event": "posted",
"data": {"channel_id": "…", "channel_type": "O", "post": "{\"id\":\"…\",\"message\":\"Hello\",…}"},
"broadcast": {"channel_id": "…", "team_id": "", "user_id": "", "omit_users": null},
"seq": 4
}
Note that data.post is the post as a JSON string. Common events: posted, post_edited, post_deleted, reaction_added, reaction_removed, typing, status_change, user_added, direct_added. Apps send their own events, named custom_<app id>_<event>.
You can also send actions, each with an increasing seq: user_typing with {"channel_id", "parent_id"}, or get_statuses. Reconnect when the connection drops, and fetch what you missed with since (see Pagination).
Antimatter adds these events: agent_activity_changed and agent_post_stream (see Agents), ai_tools_changed (see Agents), community_rules_updated and community_rules_accepted (see Community rules), focus_ended (see Focus mode), community_attributes_updated, member_timeouts_updated, post_put_on_top, post_removed_from_top, server_categories_updated, channel_overrides_updated and category_overrides_updated. A new sign-in to your own account sends session_created to your other sessions (session_id, create_at, platform, os, browser, mobile; never a token), and handling its warning sends session_revoked or session_acknowledged (session_id) to all of them.
Client libraries
- Go:
model.Client4inserver/public/model(importgithub.com/mattermost/mattermost/server/public/model).model.NewAPIv4Client(url), thenclient.SetToken(token);model.NewWebSocketClient4(wsURL, token)follows events. Antimatter's copy adds methods for its own endpoints, such asUpdateMessageRequest,PutPostOnTopandExportChannel. - TypeScript: the
Client4class of@mattermost/client, inwebapp/platform/client, with its types inwebapp/platform/types. The copy in Antimatter's repository includes Antimatter's endpoints; the package published on npm by Mattermost does not.
package main
import (
"context"
"log"
"github.com/mattermost/mattermost/server/public/model"
)
func main() {
client := model.NewAPIv4Client("https://chat.example.com")
client.SetToken("…")
post, _, err := client.CreatePost(context.Background(), &model.Post{
ChannelId: "…",
Message: "Hello from Go",
})
if err != nil {
log.Fatal(err)
}
log.Println("posted", post.Id)
}
What Antimatter adds to the API
These endpoints and fields don't exist on a Mattermost server. All of them need a session.
| Endpoint | What it does |
|---|---|
GET /teams/{team_id}/user_attributes/fieldsPOST same pathPATCH, DELETE …/fields/{field_id} | Community attributes: the profile fields a community's admins define for its members (up to 20). Members can read them; community admins create and change them. |
GET /teams/{team_id}/members/{user_id}/user_attributesPATCH same pathPOST /teams/{team_id}/user_attributes/values/search | A member's attribute values, as {field_id: value}. Members set their own (except admin-managed ones); admins set anyone's. The search takes {"user_ids": [...]} (up to 200). |
GET /teams/{team_id}/timeoutsPUT /teams/{team_id}/members/{user_id}/timeoutGET /teams/{team_id}/members/{user_id}/moderation | Timeouts: {"until": time, "reason": "…"} keeps someone from posting, reacting and uploading in the community until then (at most 28 days); "until": 0 lifts it. The moderation view gives when they joined and how much they posted lately. |
GET /channels/{channel_id}/on_topPUT, DELETE /posts/{post_id}/on_top | Messages kept on top of a channel (up to 10). {"duration": ms}, at most 30 days; 0 keeps it until removed. Anyone who can read the channel lists them. Putting a message on top or taking it off needs the right to post in the channel, and, in a community's channel, being an admin of the channel or of the community, or having Manage messages (delete_others_posts) there. In a direct or group message, its members can. Refused in archived channels. |
GET, PUT /teams/{team_id}/rulesPOST /teams/{team_id}/rules/acceptGET /teams/{team_id}/rules/stats | Community rules: see Community rules. |
GET, POST, DELETE /users/me/focusGET /users/me/focus/summary?since=&until=POST /users/me/focus/summary/read | Focus mode: see Focus mode. |
POST /channels/{channel_id}/message_request | Acts on your own message request: {"action": "accept"}, "dismiss", or "request" to put a conversation back among the requests. Returns your channel member. |
GET, PUT /channels/{channel_id}/overridesGET /channels/{channel_id}/overrides/effectiveGET, PUT /channels/{channel_id}/role_access | What a role or a person may do in a channel, and which roles see a role-gated channel. |
GET, PUT /teams/{team_id}/server_categoriesGET, PUT …/server_categories/overrides | The community's shared channel categories, and the overrides that apply to every channel in a category. |
GET /groups/{group_id}/team_rolePUT …/team_role/permissions | The permissions a community role grants. |
POST /teams/{team_id}/autolinks/preview | Tries the community's autolink rules on a text. |
POST /channels/{channel_id}/export?format=csv&since=&until= | Downloads a channel's messages as CSV (up to 50,000), for people who can read the channel. |
GET /posts/{post_id}/speech | The message read aloud, as audio, when text-to-speech is set up. |
POST /posts/{post_id}/move_to_channel | Moves a thread to another channel of the same community: see Move a thread. |
GET, POST /ai/connectionsGET, PATCH, DELETE /ai/connections/{connection_id}GET /ai/connections/usable | Model connections, the AI providers the server's AI features and apps use: see Model connections. |
GET, POST /ai/agentsGET, PATCH, DELETE /ai/agents/{agent_id}POST /ai/agents/{agent_id}/tasksGET /ai/agents/conversations | AI agents, their activity, and the tasks and conversations of the AI chat: see Agents. |
GET /ai/agents/{agent_id}/toolsGET /ai/agents/tools/cataloguePOST /ai/tools/calls/{call_id}/confirm, /declineGET, POST /ai/mcp_serversGET, PATCH, DELETE /ai/mcp_servers/{server_id}POST, DELETE /ai/mcp_servers/{server_id}/oauth | An agent's tools, the catalogue of tools it may use, confirming or declining a waiting tool call, and the MCP servers available to agents: see AI. |
GET /channels/{channel_id}/message_actionsPOST /posts/{post_id}/message_actions/{hook_id}GET, POST /hooks/outgoing/{hook_id}/icon | Message actions: outgoing webhooks that people run from the message menu. See Message actions. |
POST /users/me/sessions/acknowledge | Dismisses the new-sign-in warning of one of your own sessions: {"session_id"}. Only the signed-in user may call this, for their own sessions. |
PUT /channels/{channel_id}/type | Changes a channel's preset or its settings: see Channel types. |
GET /channels/{channel_id}/topicsPATCH /posts/{post_id}/topic | A forum's topics, and accepting an answer or setting a topic's status: see Channel types. |
New fields
- Communities (
Team, andPUT /teams/{team_id}/patch):all_channels_membership(everyone is in every channel they may see),default_channel_id(where newcomers land),welcome_channel_id(where its join and leave messages go, empty for none),kind(communityorworkplace, shown in the interface as Guild and Workspace: a guild also turnsall_channels_membershipon; new communities get the server'sTeamSettings.DefaultCommunityKind,community/Guild by default),member_list_view(how the member list groups members:choice, the default, lets each member choose;rolesorteamsfixes it),hide_role_colors,require_team_badges,name_display(how the community shows people: empty for the server's default),onboardingandautolinks(both JSON strings). - The default channel of a new community is
general(General), nottown-square. The 0.9.0 upgrade renames each community'stown-squaretogeneral, keeping its ID, unless the community already has ageneralchannel. Old names keep working:GET /teams/{team_id}/channels/name/town-square,GET /teams/name/{team}/channels/name/town-squareand an incoming webhook'schannelgive the community's default channel when no channel has that name. Usedefault_channel_id, or the namegeneral, in new code. - Channel links:
#channel-namelinks a channel, as~channel-namedoes. The post'schannel_mentionsprop lists the channels linked either way; a#wordthat names no channel stays a hashtag. Antimatter's web interface and mobile app write#name. - Archived channels still take new members (
POST /channels/{channel_id}/members), who can read their history. Nobody can post, react, pin, put a message on top, rename the channel or change its members' roles there. - Inline replies: a post with
"props": {"reply_to": "<post id>"}quotes that message of the same channel. Add"reply_to_mention": falseto leave its author unnotified. The server fillsmetadata.reply_towith a short description of the quoted message. - Spoilers:
"props": {"spoiler": true}hides a post's text and files until the reader chooses to see them. - Ignoring and blocking are preferences: category
user_block, named after the other person's ID, with the valueignoreorblock. Whether a direct message waits as a request is in your channel member'snotify_props.dm_request(pending,acceptedordismissed). - Message attachments can carry
stagesand achart: see Cards. - Channels (
Channel): every community channel'spropsalways carriesam_type(text,voiceorforum),am_mode,am_root_posts(membersormoderators) andam_preset(the matching preset's ID, or empty for custom settings), and, for forums,am_forum_view(topicsorimageboard). A payload withoutam_typemeans it wasn't filled in, never that the channel is text. Direct and group messages carry none of this. See Channel types.
Channel types
A community channel is text, voice or forum (its type, fixed when it's created), in one of its type's modes, with settings on top. A preset is a named bundle of a mode and settings that a client offers as one choice; presets aren't stored, so a channel's preset (am_preset) is derived from its settings, and reads "" when they match no preset ("Custom").
| Preset ID | Type | Mode | root_posts | view |
|---|---|---|---|---|
text_chat | text | chat | members | – |
text_announcement | text | chat | moderators | – |
voice_talk | voice | talk | members | – |
voice_stage | voice | stage | members | – |
forum_agora | forum | discussion | members | topics |
forum_qa | forum | qa | members | topics |
forum_ideas | forum | ideas | members | topics |
forum_announcement | forum | discussion | moderators | topics |
forum_imageboard | forum | discussion | members | imageboard |
Creating and changing a channel's type
POST /channels reads props.am_preset (expanded first), then the individual props.am_type, am_mode, am_root_posts and am_forum_view on top of it; any other am_ prop is dropped. Creating a voice channel while the Voice Channels app isn't active is refused (400 api.channel.type.voice_unavailable.app_error); creating a direct or group message with type props is refused (api.channel.type.direct.app_error).
curl -X POST https://chat.example.com/api/v4/channels \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"team_id": "'$TEAM_ID'", "name": "help-desk", "display_name": "Help desk", "type": "O",
"props": {"am_preset": "forum_qa"}}'
PUT /channels/{channel_id}/type changes the preset or the settings of an existing channel: {"preset"?, "mode"?, "root_posts"?, "view"?}, the preset expanded first as at creation. It needs manage_public_channel_properties or manage_private_channel_properties in the channel (audit event updateChannelType), and answers the filled-in channel. A body that names another type is refused (api.channel.type.fixed.app_error): the type itself never changes. Other errors: api.channel.type.invalid.app_error (an unknown value, a mode that isn't of the channel's type, or a view on a non-forum).
Bulk import and export (amctl import bulk) carry the same four fields on a channel's line: am_type, am_mode, am_root_posts, am_forum_view. A channel line without them imports as a plain text Chat channel, as before channel types.
Who starts conversations or opens topics
Posting a root message (as distinct from a reply) needs the permission create_root_post, shown in the overrides editor as Start conversations (text, voice) or Open topics (forums). No role holds it: before overrides, everyone who may post has it where root_posts is members, and only the channel's moderators (system admins, community admins, channel admins, and anyone with Manage messages there) where it is moderators. A role or person override on create_root_post then applies on top, as for any permission. A reply (root_id set) needs only the ordinary right to post.
A root post that fails this gets 403: app.post.root_posts.moderators_only.app_error when root_posts is moderators and the poster doesn't moderate the channel, or app.post.root_posts.denied.app_error for an override deny. This is checked for the REST API, bots, incoming and outgoing webhooks (on the hook's creator) and slash command responses; posts from apps (the plugin API) and the copies of a moved thread aren't (the person moving a thread is checked there instead). Bulk import skips it too.
Forums take only topics
In a forum, a root post needs props.am_thread_title (its topic's title), trimmed, 1 to 100 characters, from every source, apps and moved threads included: missing gets 400 app.post.forum.title_required.app_error, too long app.post.forum.title_too_long.app_error. Replies need no title, and any title sent on a reply is dropped. Editing a topic's root without a title, or with an empty one, keeps its existing title; a non-empty one renames it. Moving a thread into a forum (below) needs a title too, from the request or the thread's own.
A Q&A topic's accepted answer (am_accepted_answer, a reply's ID) and an Ideas topic's status (am_topic_status, with am_topic_status_by and am_topic_status_at) are written only by PATCH /posts/{post_id}/topic; any of these three sent on create, update or patch, by a client or an app, is dropped, and the stored value is kept.
List a forum's topics
GET /channels/{channel_id}/topics?sort=votes&per_page=25
Needs the right to read the channel. Query parameters:
sort:activity(default),newest,votes,unanswered(Q&A only) orstatus(Ideas only).answered(true/false, Q&A) andstatus(one ofopen,planned,in_progress,done,declined, Ideas): filter by these. Using either outside its mode is400.mine=true: only the topics you started.following=true: only the ones you follow.q: matches the title or the first post's text, up to 200 characters.before: the previous page'snext_cursor.per_page: 25 by default, 100 at most.replies: 0 to 5 of each topic's latest replies, for an image-board view.
Answer: {"topics": [{"post", "reply_count", "last_reply_at", "participant_ids", "votes", "accepted_answer_id", "status", "on_top", "replies": [post, …]}], "next_cursor"}. Votes are 👍 reactions (+1 and thumbsup counted together), minus the topic's own author; accepted_answer_id is given in Q&A forums only, status in Ideas forums only. Topics on top of the channel come first.
Accept an answer, or set a topic's status
curl -X PATCH https://chat.example.com/api/v4/posts/$POST_ID/topic \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"accepted_answer_id": "'$REPLY_ID'"}'
post_id must be a forum topic's root post, else 400 app.topic.not_a_topic.app_error. accepted_answer_id (a reply's ID, or "" to take it back) needs a Q&A forum; status needs an Ideas forum; either in the wrong mode is 400 app.topic.mode.app_error.
- Accepting an answer: the topic's asker (while they may still post there) or a channel moderator; otherwise
403 app.topic.accept.not_allowed.app_error. The reply must be a live, non-system reply of that topic, or400 app.topic.accept.not_a_reply.app_error. Deleting the accepted reply clears the answer. - Setting a status: channel moderators only, else
403 app.topic.status.not_allowed.app_error; an unknown value is400 app.topic.status.invalid.app_error.
Each change posts a system reply in the topic (telling its followers) and answers with the topic's root post, as it now stands. In an Ideas forum, voting for a topic also makes you follow it.
Move a thread
POST /posts/{post_id}/move_to_channel moves a thread, its first message and all its replies, to another channel of the same community. post_id can be the first message or any reply.
curl -X POST https://chat.example.com/api/v4/posts/$POST_ID/move_to_channel \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"channel_id": "'$TARGET_CHANNEL_ID'", "flatten": false}'
channel_id(required): the channel the thread moves to.flatten(defaultfalse):truemakes the replies messages of the target channel instead of a thread. Fusion's Make it a channel uses it. Refused when the target is a forum (400 app.post.move_thread.forum_flatten.app_error): its replies would become topics without titles.title: the topic's title, when the target is a forum. Without it, the thread's own title is kept, if it has one; missing either way is400 app.post.move_thread.forum_title_required.app_error. Ignored for a target that isn't a forum.
The messages keep their authors, times, reactions and files. Their copies carry the silent_notification prop, so they notify nobody. The original messages are deleted. The answer is the moved first message, with its new ID.
Who may move a thread
- You must be able to read the thread and to post in the target channel.
- The channel's admins, the community's admins, people with Manage messages (
delete_others_posts) in the channel, and system admins may move any thread. - The thread's author may move it when both channels are public, or when every message of the thread is theirs. Otherwise the answer is
403with the IDapi.post.move_thread.private_needs_moderator.app_error. - Both channels must be in the same community (no direct or group messages), different, and not archived. System messages and burn-on-read messages can't be moved.
- A thread longer than
WranglerSettings.MoveThreadMaxCountmessages (100 by default; 0 for no limit) is refused.
The pointer and the note
The move leaves two system messages of type system_wrangler: a pointer in the old channel ("A thread with 13 messages has been moved to #target") and a note in the target ("This thread was moved from #source"), under the moved thread or, with flatten, after its messages. Besides the message text, their props let a client draw the channel name as a link:
- The pointer:
MovedThreadPermalink,NumMessages,MovedThreadChannelId,MovedThreadTeamName,MovedThreadPostId, and, for a public target,MovedThreadChannelNameandMovedThreadChannelDisplayName. - The note:
MovedFromChannelId,MovedFromTeamName,MovedFromPostId(the pointer),MovedFromPermalink, and, for a public source,MovedFromChannelNameandMovedFromChannelDisplayName.
Both also have a TranslationID. A private channel's name is left out: readers see "another channel". Mattermost's own POST /posts/{post_id}/move stays off in Antimatter; use this endpoint.
Community rules
A community's admins can make its members agree to its rules before they post in its channels. While the rules are on, a member who hasn't agreed to the current version can't post, reply, send files, schedule, edit or restore a message, or run a command that posts in the community's channels: the server answers 403 with the ID api.post.community_rules_not_accepted, and a scheduled message due then fails with the error code community_rules_not_accepted. Reading, reactions, and direct and group messages aren't held back; neither are bots, incoming webhooks, system messages, and the community's admins (manage_team, which system admins have too).
| Endpoint | What it does |
|---|---|
GET /teams/{team_id}/rules | The rules (enabled, text in Markdown, version), with the latest version you agreed to (accepted_version) and whether you must agree before you post (must_accept). For members, and for anyone who may join an open community. |
PUT /teams/{team_id}/rules | Saves them, with {"enabled", "text", "ask_again"} (manage_team). ask_again makes a new version that everyone must agree to again; the first save is version 1. The text is required to turn them on, at most 16,000 characters. |
POST /teams/{team_id}/rules/accept | Agrees, as a member, to the version you read; 409 when a newer version was saved since, and 0 agrees to the current one. |
GET /teams/{team_id}/rules/stats | How many of the community's members agreed to the current version (manage_team). |
The community_rules_updated event goes to the community when an admin saves the rules (team_id, enabled, version), and community_rules_accepted to the one who agreed, on all their sessions (team_id, version).
Focus mode
Focus mode is Do not disturb with an end time: notifications are muted (no push, email, desktop or persistent notifications, urgent messages included), the reason becomes the custom status with the 🎯 emoji (dart), expiring at the end, and Antimatter's apps hide the person's own unread badges. Only the person themself can use these endpoints (me or their own ID): nobody else, system admins included, may start, end or read someone's focus or its summary.
| Endpoint | What it does |
|---|---|
GET /users/me/focus | The current focus (active, start_at, end_at, reason), or the last one once it ended (ended_at set), or all zero. |
POST /users/me/focus | Starts focusing, or while focusing changes the end and the reason: {"duration": "1h30m", "reason": "…"}, or {"end_at": ms} instead of the duration, from a minute to a week away. "duration": "tomorrow" ends at 09:00 the next day in the person's time zone. The end is rounded down to the minute; the reason is at most 100 characters. Needs user statuses on (ServiceSettings.EnableUserStatuses); bots can't focus. |
DELETE /users/me/focus | Ends the focus now. |
GET /users/me/focus/summary?since=&until= | What was missed in that window (a focus's start_at and ended_at): the channels with unread messages from others (direct and group messages first, then those with mentions; muted channels only with a mention), and, with collapsed reply threads, the followed threads with new replies, each with its count, mentions and last sender. |
POST /users/me/focus/summary/read | With {"since", "until"}: marks those channels and threads read up to until only; what came after stays unread. |
While someone focuses, their user has the prop focus_until (milliseconds), which clients use to show the focusing status; it can't be changed through the profile. The focus ends when the person ends it, when its time comes (within a minute), or when they pick a status themself, which then stays. Otherwise the status and custom status from before come back. All their sessions then get the focus_ended event, with start_at, end_at and ended_by (user, timer or status).
Model connections
A model connection is an AI provider's address and key, set up by an admin. The server's AI features and the apps that call the AI service use them. System admins manage the server-wide connections (manage_ai_connections); a community's admins manage their community's own (manage_team_ai_connections). Keys are write-only: no answer carries one, only api_key_set and api_key_last4.
| Endpoint | What it does |
|---|---|
GET /ai/connections?scope= | The connections you manage. scope is server or a community's ID; without it, every scope you manage. |
POST /ai/connections | Adds a connection (answers 201). |
POST /ai/connections/test, POST /ai/connections/models | Tests a connection, or lists its models, before it is saved. Same body as adding one; nothing is stored. |
GET, PATCH (or PUT), DELETE /ai/connections/{connection_id} | Reads, changes or deletes a connection. A patch carries only the fields to change; leave out api_key to keep the key. |
POST /ai/connections/{connection_id}/test | Tests a saved connection: {"ok", "error", "status_code", "latency_ms", "model_count"}. |
GET /ai/connections/{connection_id}/models | The models the provider offers. |
GET /ai/connections/{connection_id}/usage | This month's requests and tokens. |
GET /ai/connections/usable?channel_id=&team_id= | For anyone signed in: the connections you may use there, with their models and capabilities, never their keys or addresses. |
The body of a new connection:
{
"name": "Local models",
"scope": "server",
"provider": "openai_compatible",
"base_url": "http://llm.internal:8000/v1",
"api_key": "…",
"extra_headers": {},
"models": {"chat": "qwen3-32b", "embeddings": "", "transcription": "", "decisions": ""},
"limits": {"requests_per_minute": 0, "monthly_token_budget": 0, "per_user_requests_per_minute": 0, "per_user_monthly_token_budget": 0},
"sharing": {"all_teams": true, "team_ids": []},
"audience": {"everyone": true, "group_ids": [], "roles": [], "allow_guests": false}
}
provider is openai_compatible or anthropic. scope defaults to server; a community's ID makes it that community's connection. In limits, 0 means no limit; requests per minute are counted on each server, token budgets per calendar month (UTC) for the whole cluster. sharing says which communities may use a server-wide connection (direct and group messages need all_teams), and audience who may use it; guests only with allow_guests. Changes are written to the audit log. The admin guide explains the settings: see AI.
Agents
An agent is a bot account of the kind agent (is_agent on its user), with its settings: its scope (server, or a community's ID), built-in or external, and for a built-in agent its model connection, model, system prompt and reasoning. System admins manage the server-wide agents (manage_system), a community's admins their community's (manage_team); on top of that, manage_own_agent for an agent they made and manage_others_agent for the others. The agent's ID in these paths is its user ID. Running an external agent, and how a built-in agent's status is set, is explained in Bots; PUT /users/{user_id}/status and the custom status endpoints refuse a built-in agent's ID, for system admins too (403, api.status.agent.app_error).
| Endpoint | What it does |
|---|---|
GET /ai/agents?scope= | The agents you manage: scope is server or a community's ID; without it, every agent you manage. |
POST /ai/agents | Makes an agent and its account (answers 201). A community's agent joins its community. |
GET, PATCH, DELETE /ai/agents/{agent_id}POST …/enable, POST …/disable | Reads, changes, deletes (its account is deactivated, its messages stay), turns on or off an agent. |
GET /ai/agents/connections?scope=GET /ai/agents/connections/{connection_id}/models | The model connections an agent of that scope may use, and their models. |
GET, PUT /ai/agents/{agent_id}/activityPOST /ai/agents/activity/ids | What an agent is doing; set by its own token or its managers. The batch takes up to 200 user IDs. |
POST /ai/agents/{agent_id}/tasks | Asks a built-in agent you may use for a task: summarize_thread, find_action_items or find_open_questions (on a thread's post_id, or a channel_id from since on), summarize_channel, ask_channel (with a question) or summarize_transcription (a call's transcription post). The request becomes your message in your direct message with the agent, in your language, and is answered there; the answer (201) is that message. A post or channel you can't read is answered as one that isn't there. |
GET /ai/agents/conversations?agent_id=&before=&per_page= | Your conversations with the agents you may use, the most recent first: the threads you started in your direct messages with them, and the threads elsewhere one of them replied in that you follow. before (a time) gives the next page; per_page is 100 at most. |
The body of a new agent:
{
"username": "researcher",
"display_name": "Researcher",
"description": "Answers with sources",
"scope": "server",
"external": false,
"connection_id": "…",
"model": "",
"system_prompt": "You research questions and answer with sources.",
"reasoning": {"enabled": false, "effort": "", "budget_tokens": 0},
"max_tool_turns": 0,
"access": {"everyone": true, "team_ids": [], "channel_ids": [], "roles": [], "user_ids": [], "allow_guests": false}
}
An empty model uses the connection's chat model. access.channel_ids means the members of those channels. An external agent has no connection, model or system prompt. GET /agents lists the agents you may talk to, as the AI chat app reads them.
The assistant
The assistant (see AI) gives each signed-in person their own feed of cards for a channel they may read: a member, for private channels, direct and group messages. Base path /api/v4/assistant. Every route is the caller's own: nobody reads or changes another person's feed, a card ID that isn't theirs is 404, and bots and deactivated accounts get 403.
| Endpoint | What it does |
|---|---|
GET /channels/{channel_id}/feed | The person's feed: pinned cards first, then the newest, dismissed ones left out. Each card has a kind (insight, read-only, with a result of at most 4000 characters, or action, with no result), a why (the rules engine's matched words and entities, or the model engine's probabilities; a trusted app's card also has why.app), and, for an action, an offer (status open/done/expired, holder_id, offered_to_me, can_run, moves_at). |
POST /channels/{channel_id}/presence | {"open": true|false}. Answers {"following", "ttl_seconds": 90, "heartbeat_seconds": 30}: send it again every heartbeat_seconds while the panel stays open on that channel. |
PUT /channels/{channel_id}/enabled | The person's own switch for the channel: {"on": true|false}. Turning it off also closes the presence and forgets the feed there. |
POST .../cards/{card_id}/dismiss, /pin ({"pinned": true|false}), /mute | Dismisses, pins or unpins, or mutes the card's tool for the person everywhere and dismisses the card ("Don't suggest this kind again"). Dismissing an action passes its offer to the next person. |
POST .../cards/{card_id}/claim | Takes an action offered to nobody (holder_id empty). Already the caller's: unchanged 200. Someone else's: 403 app.assistant.offer_not_yours.app_error. Done: 409 app.assistant.offer_done.app_error. |
POST .../cards/{card_id}/run | Only the offer's holder, once: closes the offer for everyone and audits it, but executed stays false in this release — the tool doesn't run yet. Not the holder: 403; a second run: 409; the offer expired (a day): 410 app.assistant.offer_gone.app_error. |
POST .../cards/{card_id}/post | Posts an insight's result to its channel as the person, with the ordinary right to post there: 201 and the post, its message **<tool_title>**\n<result> and its prop assistant_card the card's ID. An action: 400 app.assistant.post_insight_only.app_error. |
GET, PUT /muted_tools | {"tools": [...]}. PUT replaces the list (at most 200). |
GET, PUT /communities/{team_id}/settings | For a community's admins (manage_team_ai_connections): the community's own override of enabled, engine, connection_id and model, when the server allows it (AssistantSettings.AllowCommunityOverride). A community can turn the assistant off, never on when the server has it off; its connection must be its own or a server-wide one shared with it. |
Three WebSocket events, each to the one person's own clients only (never a channel or a community): assistant_card_added ({"channel_id", "card"}), assistant_card_updated (dismissed, pinned, unpinned or posted, from any of their clients; same data), and assistant_offer_updated, sent to everyone who has that offer's card, each with their own view of it ({"channel_id", "card_id", "offer"}).
A trusted app's own post can start the assistant like a person's message (AssistantSettings.TriggeringApps): see Trusted apps.