Bots and interactive messages
A bot account is a user made for a program. It has its own name, picture and tokens, and shows a BOT tag next to its name. This page also covers AI agents you run yourself, and the interactive parts of messages: buttons, menus and dialogs, which any integration can use.
Why a bot account
- Its messages come from a clear identity, with a BOT tag, instead of from a colleague's account.
- Its tokens keep working when the person who set it up changes role or leaves (unless the server disables the bots of deactivated owners, see below).
- Bots can't sign in with a password, and they never see the web app.
Apps create their own bots, such as the Todo or Playbooks bots, through the plugin API (EnsureBotUser). AI agents are bot accounts too, of their own kind, shown with an AGENT tag: see Agents.
Turn bot accounts on
A system admin turns on Enable Bot Account Creation in System Console › Integrations › Bot Accounts (ServiceSettings.EnableBotAccountCreation, AM_SERVICESETTINGS_ENABLEBOTACCOUNTCREATION). It is off by default. Only system admins can create bots, unless they give the right to other roles in the permission settings.
On the same page, Disable bot accounts when owner is deactivated (ServiceSettings.DisableBotsWhenOwnerIsDeactivated, on by default) turns a bot off when its owner's account is deactivated.
Create a bot
- Open the community menu, then Community settings, click Integrations at the end of its tabs, then Bot accounts and Add Bot Account.
- Enter a Username (lowercase letters, numbers, periods, dashes and underscores), and optionally a Bot Icon, a Display Name and a Description.
- Choose the Role: Member, or System Admin for a bot that manages the server. Give a bot the least it needs.
- Tick post:channels to let it post in every public channel, or post:all for every channel, private ones and direct messages included, without being a member.
- Set when its default token expires, then choose Create Bot Account.
- Copy the access token shown. It is not shown again.
Later, Create New Token on the bot's row adds a token, and each token can be disabled or deleted. Bot tokens work even when personal access tokens are turned off for people.
A bot can carry server attributes (the ones set up in System Console › System Attributes), set on the Add/Edit Bot form or from Edit attributes… in the bot's ⋯ menu. Whoever manages the bot sets them: its owner, with the manage bots permission, or anyone with manage others' bots; an admin-managed attribute still needs a system admin. An AI agent's bot belongs to no one, so only someone with manage others' bots or a system admin can set its attributes.
With the API:
# create the bot (you become its owner)
curl -X POST https://chat.example.com/api/v4/bots \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"username": "deploy-bot", "display_name": "Deploy", "description": "Posts deployments"}'
# give it a token
curl -X POST https://chat.example.com/api/v4/users/$BOT_USER_ID/tokens \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"description": "deploy pipeline"}'
From the command line, signed in as an admin (not in local mode): amctl bot create deploy-bot --display-name Deploy --with-token --expires-in 90d. Other endpoints: GET /api/v4/bots, PUT /api/v4/bots/{bot_user_id}, POST /api/v4/bots/{bot_user_id}/disable and /enable, POST /api/v4/bots/{bot_user_id}/assign/{user_id} to change its owner.
Add the bot to communities and channels
A new bot is in no community. Add it like a person, from the community's member management or with the API:
curl -X POST https://chat.example.com/api/v4/teams/$TEAM_ID/members \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"team_id": "'$TEAM_ID'", "user_id": "'$BOT_USER_ID'"}'
curl -X POST https://chat.example.com/api/v4/channels/$CHANNEL_ID/members \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"user_id": "'$BOT_USER_ID'"}'
A bot with post:channels or post:all can post without being a member, but it only receives WebSocket events for the channels it is in.
Post as the bot
Use the bot's token with any API call; POST /api/v4/posts is the most common (see Main resources). To write to someone directly, open the direct message channel first:
# returns the channel; use its "id" as channel_id
curl -X POST https://chat.example.com/api/v4/channels/direct \
-H "Authorization: Bearer $BOT_TOKEN" -H 'Content-Type: application/json' \
-d '["'$BOT_USER_ID'", "'$USER_ID'"]'
To react to what people say, follow the WebSocket with the bot's token and answer the posted events you care about. Ignore the bot's own messages to avoid loops.
A message only one person can see (an ephemeral message) is sent with POST /api/v4/posts/ephemeral and {"user_id", "post": {"channel_id", "message"}}. It needs the create_post_ephemeral permission, which only system admins have by default. Integrations usually send ephemeral messages as answers to slash commands and buttons instead.
Agents
An agent is a bot account of the kind agent: users have "is_agent": true, bots "kind": "agent", and the interface shows an AGENT tag instead of BOT. Agents are made only by system admins (server-wide agents), in System Console › AI › Connections and agents, and community admins (their community's), in the community settings' AI tab, or with /api/v4/ai/agents (see the API). A bot made with POST /bots or by an app is always a plain bot.
A built-in agent is answered by the server, with a model connection. An external agent is yours to run: a program elsewhere (a coding agent on a project, for instance) that signs in as the agent and answers like a person would. Antimatter doesn't answer for it.
A built-in agent's status is set by the server: online while its model connection answers, away while it doesn't (down, a timeout, a refused key), offline when it is off or has no usable connection or model. It is checked when the agent or its connection changes, when a connection is tested, on each of the agent's model calls, and every 10 minutes. Nobody can set a built-in agent's status: PUT /users/{id}/status and the custom status endpoints answer 403 (api.status.agent.app_error), for system admins too, and the plugin API's UpdateUserStatus, SetUserStatusTimedDND, UpdateUserCustomStatus and RemoveUserCustomStatus refuse it the same way. An external agent's status follows its own connection instead, and only its own token can change it.
Run an external agent
- An admin adds the agent in System Console › AI › Connections and agents (or a community's AI tab), with the kind External agent, and chooses who may talk to it. The page then shows how to connect it.
- Give it a token: a system admin opens Integrations › Bot accounts, finds the agent's account and chooses Create New Token, or calls the tokens API with the agent's user ID:
curl -X POST https://chat.example.com/api/v4/users/$AGENT_USER_ID/tokens \ -H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' \ -d '{"description": "agent"}' - The program calls the API and opens the WebSocket with
Authorization: Bearer <token>, reads its mentions and direct messages from thepostedevents, and answers withPOST /posts, in the message's thread. - It shows people what it is doing by setting its activity:
curl -X PUT https://chat.example.com/api/v4/ai/agents/$AGENT_USER_ID/activity \ -H "Authorization: Bearer $AGENT_TOKEN" -H 'Content-Type: application/json' \ -d '{"state": "working", "text": "Running the tests", "thread_post_id": "'$ROOT_ID'"}'
The agent's ID in these paths is its user ID. Keep the token secret: anyone who has it can post as the agent.
Activity
state:idle,thinking,working,waiting(for someone),blockedoroffline. The busy states (thinking, working, waiting, blocked) go back toidleafterexpires_in_seconds: 10 minutes by default, 24 hours at most.text: a few words on the task, one line of 200 characters at most. The interface shows it in the member list and on the profile card.thread_post_id: the thread it works in, which the program must be able to read. It is only given to those who may read it.
Who can see the agent can read its activity (GET /ai/agents/{agent_id}/activity, or POST /ai/agents/activity/ids with a list of IDs) and receives the agent_activity_changed WebSocket event, without the thread. Built-in agents set their activity themselves.
While a built-in agent writes its answer, the server sends the growing text in agent_post_stream events to the channel: post_id, channel_id, root_id, user_id, message (the text so far) and control (start, text, end, error or cancel). A post_edited event brings the final message. Clients that ignore the stream see the answer once it is saved.
Interactive messages: buttons and menus
Any message with attachments can carry actions: buttons, and menus to pick from. They work in messages posted with the API, webhooks and slash command answers. When someone uses one, Antimatter calls your integration's URL.
{
"channel_id": "…",
"message": "Deploy **api 2.4.0** to production?",
"props": {
"attachments": [{
"actions": [
{
"id": "approve",
"type": "button",
"name": "Approve",
"style": "primary",
"integration": {"url": "https://deploy.example.com/actions", "context": {"release": "2.4.0", "action": "approve"}}
},
{
"id": "env",
"type": "select",
"name": "Pick an environment",
"options": [{"text": "Staging", "value": "staging"}, {"text": "Production", "value": "production"}],
"integration": {"url": "https://deploy.example.com/actions", "context": {"release": "2.4.0", "action": "env"}}
}
]
}]
}
}
type:buttonorselect.id: letters, numbers, dashes and underscores, different for each action of the message. It is generated when you leave it out.style(buttons):default,primary,success,good,warning,danger, or a hex colour.options(menus), ordata_source:usersorchannelsto pick a person or a channel.default_optionpreselects a value.integration.url: where the click is sent. An app (plugin) can use its own route, such as/plugins/com.example.deploy/actions.integration.contextis yours: it comes back with the click, and clients never see it.
The request your integration receives
{
"user_id": "…",
"user_name": "priya",
"channel_id": "…",
"channel_name": "releases",
"team_id": "…",
"team_domain": "lumen-labs",
"post_id": "…",
"trigger_id": "…",
"type": "button",
"context": {"release": "2.4.0", "action": "approve"}
}
For a menu, type is select and context.selected_option holds the chosen value.
Your answer
{
"update": {"message": "Deploy of **api 2.4.0** approved by @priya", "props": {}},
"ephemeral_text": "Done. The deploy starts in a minute."
}
update: replaces the message's text and properties, for example to remove the buttons once used (an emptyprops).ephemeral_text: a message only the person who clicked sees.goto_location: a URL to open.- An empty body changes nothing.
The server waits as long as the Integration request timeout allows (30 seconds by default). Integration URLs on private network addresses must be allowed by a system admin, as for webhooks.
Dialogs
A dialog is a form that opens over the chat. You can open one only in answer to something the person just did, a slash command or a button, because it needs that request's trigger_id. The trigger_id is valid for as long as the integration request timeout.
curl -X POST https://chat.example.com/api/v4/actions/dialogs/open \
-H 'Content-Type: application/json' \
-d '{
"trigger_id": "'$TRIGGER_ID'",
"url": "https://deploy.example.com/dialog",
"dialog": {
"callback_id": "new-release",
"title": "New release",
"introduction_text": "Fill in the release details.",
"submit_label": "Create",
"notify_on_cancel": false,
"state": "anything you want back",
"elements": [
{"display_name": "Version", "name": "version", "type": "text", "placeholder": "2.4.0"},
{"display_name": "Notes", "name": "notes", "type": "textarea", "optional": true},
{"display_name": "Environment", "name": "env", "type": "select",
"options": [{"text": "Staging", "value": "staging"}, {"text": "Production", "value": "production"}]},
{"display_name": "Owner", "name": "owner", "type": "select", "data_source": "users"},
{"display_name": "Notify the channel", "name": "notify", "type": "bool", "default": "true"}
]
}
}'
| Element type | What it shows |
|---|---|
text | A one-line field. subtype: text, email, number, password, tel or url. min_length and max_length limit it. |
textarea | A multi-line field. |
select | A list: options, or data_source users, channels, or dynamic with a data_source_url. multiselect allows several values. |
bool | A checkbox. |
radio | One choice among options. |
date, datetime | A date, or a date and time. |
action_button | A button that calls action_button_url on its own, separately from the dialog's submit. Its help_text shows under it. |
collapsible | A foldable group of elements. With sub_type: "grid", its child containers lay out as a table instead of stacking: each one is a row, and its own elements are that row's cells. |
Every element takes display_name, name, help_text, placeholder, default and optional.
submit_buttons on the dialog names a static select element: its options become the dialog's submit buttons, one per option, in place of a single Submit, and that select itself isn't shown. size ("small", "medium" or "large"; "small" if left out) asks for a wider dialog. refresh_on_return (needs source_url) re-fetches the dialog from source_url once a dialog opened on top of it closes, so it can show what changed. An unrecognised size or sub_type doesn't stop the dialog from opening: it falls back to the default and the server only logs it.
When the person submits, Antimatter POSTs to the dialog's url:
{
"type": "dialog_submission",
"callback_id": "new-release",
"state": "anything you want back",
"user_id": "…",
"channel_id": "…",
"team_id": "…",
"submission": {"version": "2.4.0", "notes": "", "env": "staging", "owner": "…", "notify": true},
"cancelled": false
}
Answer with an empty body to close the dialog, or keep it open and show what's wrong:
{"errors": {"version": "This version already exists."}}
{"error": "…"} shows a message for the whole form. With notify_on_cancel, closing the dialog sends "cancelled": true. Dialogs work the same way in apps: the plugin API's OpenInteractiveDialog takes the same request.