Status: downloadable plugin (bot token + WebSocket events). Channels, private channels, group DMs, and DMs are supported. Mattermost is a self-hostable team messaging platform (mattermost.com).
Install
npm registry
```bash
apus plugins install @openclaw/mattermost
```
Local checkout
```bash
apus plugins install ./path/to/local/mattermost-plugin
```
Details: Plugins
Quick setup
Ensure plugin is available
Install `@openclaw/mattermost` with the command above. Check the [application result](/plugins/manage-plugins#apply-changes-and-inspect) before continuing.
Create a Mattermost bot
Create a Mattermost bot account, copy the **bot token**, and add the bot to the teams and channels it should read.
Copy the base URL
Copy the Mattermost **base URL** (e.g., `https://chat.example.com`). A trailing `/api/v4` is stripped automatically.
Configure Apus and start the gateway
Minimal config:
```json5
{
channels: {
mattermost: {
enabled: true,
botToken: "mm-token",
baseUrl: "https://chat.example.com",
dmPolicy: "pairing",
},
},
}
```
Non-interactive alternative:
```bash
apus channels add --channel mattermost --bot-token <token> --http-url https://chat.example.com
```
Self-hosted Mattermost on a private/LAN/tailnet address: outbound Mattermost API requests pass through an SSRF guard that blocks private and internal IPs by default. Opt in with channels.mattermost.network.dangerouslyAllowPrivateNetwork: true (per account: channels.mattermost.accounts.<id>.network.dangerouslyAllowPrivateNetwork).
Native slash commands
Native slash commands are opt-in. When enabled, Apus registers oc_* slash commands on every team the bot is a member of and receives callback POSTs on the gateway HTTP server.
{
channels: {
mattermost: {
commands: {
native: true,
nativeSkills: true,
callbackPath: "/api/channels/mattermost/command",
// Use when Mattermost cannot reach the gateway directly (reverse proxy/public URL).
callbackUrl: "https://gateway.example.com/api/channels/mattermost/command",
},
},
},
}
Registered commands: /oc_status, /oc_model, /oc_models, /oc_new, /oc_help, /oc_think, /oc_reasoning, /oc_verbose, /oc_queue. With nativeSkills: true, skill commands are also registered as /oc_<skill>.
Behavior notes
- `native` and `nativeSkills` default to `"auto"`, which resolves to disabled for Mattermost. Set them to `true` explicitly.
- `callbackPath` defaults to `/api/channels/mattermost/command`.
- If `callbackUrl` is omitted, Apus derives `http://<gateway.customBindHost or localhost>:<gateway.port, default 18789><callbackPath>`. Wildcard bind hosts (`0.0.0.0`, `::`) fall back to `localhost`.
- For multi-account setups, `commands` can be set at the top level or under `channels.mattermost.accounts.<id>.commands` (account values override top-level fields).
- Existing slash commands with the same trigger created by other integrations are left untouched (registration skips them); commands the bot created are updated or recreated when the callback URL drifts.
- Command callbacks are validated with the per-command tokens returned by Mattermost when Apus registers `oc_*` commands.
- Apus refreshes current Mattermost command registration before accepting each callback, so stale tokens from deleted or regenerated slash commands stop being accepted without a gateway restart.
- Callback validation fails closed if the Mattermost API cannot confirm the command is still current; failed validations are cached briefly, concurrent lookups are coalesced, and fresh lookup starts are rate-limited per command to bound replay pressure.
- Slash callbacks fail closed when registration failed, startup was partial, or the callback token does not match the resolved command's registered token (a token valid for one command cannot reach upstream validation for a different command).
- Accepted callbacks are acknowledged with an ephemeral "Processing..." reply; the real answer arrives as a normal message.
Reachability requirement
The callback endpoint must be reachable from the Mattermost server.
- Do not set `callbackUrl` to `localhost` unless Mattermost runs on the same host/network namespace as Apus.
- Do not set `callbackUrl` to your Mattermost base URL unless that URL reverse-proxies `/api/channels/mattermost/command` to Apus.
- A quick check is `curl https://<gateway-host>/api/channels/mattermost/command`; a GET should return `405 Method Not Allowed` from Apus, not `404`.
Mattermost egress allowlist
If your callback targets private/tailnet/internal addresses, set Mattermost `ServiceSettings.AllowedUntrustedInternalConnections` to include the callback host/domain.
Use host/domain entries, not full URLs.
- Good: `gateway.tailnet-name.ts.net`
- Bad: `https://gateway.tailnet-name.ts.net`
Environment variables (default account)
Set these on the gateway host if you prefer env vars:
MATTERMOST_BOT_TOKEN=...MATTERMOST_URL=https://chat.example.com
Env vars apply only to the default account (default). Other accounts must use config values.
MATTERMOST_URL cannot be set from a workspace .env; see Workspace .env files.
Chat modes
Mattermost responds to DMs automatically. Channel behavior is controlled by chatmode:
oncall (default)
Respond only when @mentioned in channels.
onmessage
Respond to every channel message.
onchar
Respond when a message starts with a trigger prefix.
Config example:
{
channels: {
mattermost: {
chatmode: "onchar",
oncharPrefixes: [">", "!"], // default
},
},
}
Notes:
oncharstill responds to explicit @mentions.channels.mattermost.requireMentionis still honored, butchatmodeis preferred. Per-channelgroups.<channelId>.requireMentionsettings win over both.- After the bot sends a visible reply in a channel thread, later messages in that same thread are answered without a new @mention or
oncharprefix, so multi-turn thread conversations keep flowing. Participation is remembered for 7 days after the bot last replied in that thread and persists across gateway restarts. Threads the bot has only observed are unaffected; start a new top-level message to require an explicit mention again. - Set
channels.mattermost.implicitMentions.threadParticipation: falseto stop participated-thread follow-ups from bypassing mention gating. Account overrides usechannels.mattermost.accounts.<id>.implicitMentions. Mattermost does not producereplyToBotorquotedBotfacts, so those flags have no effect here.
Set channels.mattermost.requireMentionInBotThreads: false to accept follow-ups
without an @mention or onchar prefix in threads whose root post was sent by the
receiving bot. Set it to true to require explicit activation there even after
the bot has participated. Omitting it preserves the behavior above.
Account settings override the channel-wide value. For an individual channel,
groups.<channelId>.requireMentionInBotThreads overrides
groups["*"].requireMentionInBotThreads, then the account value. Apus verifies
the root post's author and channel through Mattermost; unavailable or deleted
roots keep the existing mention behavior. Sender and channel restrictions still
apply, and top-level messages and threads rooted in someone else's post are
unchanged.
Threading and sessions
Use channels.mattermost.replyToMode to control whether channel and group replies stay in the main channel or start a thread under the triggering post.
off(default): only reply in a thread when the inbound post is already in one.first: for top-level channel/group posts, start a thread under that post and route the conversation to a thread-scoped session.allandbatched: same behavior asfirstfor Mattermost, because once Mattermost has a thread root, follow-up chunks and media continue in that same thread.- Direct messages default to
offeven whenreplyToModeis set.
Use channels.mattermost.replyToModeByChatType to override the mode for direct, group, or channel chats. Set direct to opt direct messages into threading:
off(default): direct messages stay non-threaded in one rolling session.first,all, orbatched: each top-level direct message starts a Mattermost thread backed by a fresh, independent session.
{
channels: {
mattermost: {
replyToMode: "all",
replyToModeByChatType: {
direct: "first",
},
},
},
}
Notes:
- Thread-scoped sessions use the triggering post id as the thread root.
firstandallare equivalent because once Mattermost has a thread root, follow-up chunks and media continue in that same thread.- Per-chat-type overrides take precedence over
replyToMode. Without adirectoverride, existing deployments keep flat, non-threaded DMs. - After a restart or session reset, thread-scoped conversations recover recent messages from Mattermost when their pending history is cold. This includes DMs with threading enabled; flat DMs are unchanged. Recovery respects sender access and context visibility, excludes the triggering message, and is bounded by
historyLimit, a 200-post server window, and a five-second deadline. A temporary server failure does not block the new message indefinitely.
Access control (DMs)
- Default:
channels.mattermost.dmPolicy = "pairing"(unknown senders get a pairing code). Other values:allowlist,open,disabled. - Approve via:
apus pairing list mattermostapus pairing approve mattermost <CODE>
- Public DMs:
channels.mattermost.dmPolicy="open"pluschannels.mattermost.allowFrom=["*"](the config schema enforces the wildcard). channels.mattermost.allowFromaccepts user ids (recommended) andaccessGroup:<name>entries. See Access groups.
Channels (groups)
- Default:
channels.mattermost.groupPolicy = "allowlist"(mention-gated). - Allowlist senders with
channels.mattermost.groupAllowFrom(user IDs recommended). channels.mattermost.groupAllowFromacceptsaccessGroup:<name>entries. See Access groups.- Per-channel mention overrides live under
channels.mattermost.groups.<channelId>.requireMentionorchannels.mattermost.groups["*"].requireMentionfor a default. - Text commands can follow the mention:
@<bot-username> /newruns/newwhilecommands.textis enabled (the default). Mattermost itself executes a bare/newas a Mattermost slash command instead of posting it. @usernamematching is mutable and only enabled whenchannels.mattermost.dangerouslyAllowNameMatching: true.- Open channels:
channels.mattermost.groupPolicy="open"(mention-gated). - Resolution order:
channels.mattermost.groupPolicy, thenchannels.defaults.groupPolicy, then"allowlist". - Runtime note: if the
channels.mattermostsection is completely missing, runtime fails closed togroupPolicy="allowlist"for group checks (even ifchannels.defaults.groupPolicyis set) and logs a one-time warning.
Example:
{
channels: {
mattermost: {
groupPolicy: "open",
groups: {
"*": { requireMention: true },
"team-channel-id": { requireMention: false },
},
},
},
}
Targets for outbound delivery
Use these target formats with apus message send or cron/webhooks:
| Target | Delivers to |
|---|---|
channel:<id> |
Channel by id |
channel:<name> or #channel-name |
Channel by name, searched across the teams the bot belongs to |
user:<id> or mattermost:<id> |
DM with that user |
@username |
DM (username resolved via the Mattermost API) |
Outbound sends support at most one attachment per message; split multiple files into separate sends.
Set channels.mattermost.mediaMaxMb to limit each inbound download and outbound
attachment in MiB. accounts.<id>.mediaMaxMb overrides the channel root, then
agents.defaults.mediaMaxMb supplies the fallback. Without any configured cap,
inbound downloads retain their 8 MiB default and outbound media retains the
shared loader defaults. Outbound images may be optimized. With a configured cap,
download or upload failures fail the send instead of posting the unchecked original
URL. Without a configured cap, the existing URL fallback remains available.
Bare opaque IDs (like 64ifufp...) are ambiguous in Mattermost (user ID vs channel ID).
Apus resolves them user-first:
- If the ID exists as a user (
GET /api/v4/users/<id>succeeds), Apus sends a DM by resolving the direct channel via/api/v4/channels/direct. - Otherwise the ID is treated as a channel ID.
If you need deterministic behavior, always use the explicit prefixes (user:<id> / channel:<id>).
DM channel retry
When Apus sends to a Mattermost DM target and needs to resolve the direct channel first, it retries transient direct-channel creation failures by default.
Use channels.mattermost.dmChannelRetry to tune that behavior globally for the Mattermost plugin, or channels.mattermost.accounts.<id>.dmChannelRetry for one account. Defaults:
{
channels: {
mattermost: {
dmChannelRetry: {
maxRetries: 3,
initialDelayMs: 1000,
maxDelayMs: 10000,
timeoutMs: 30000,
},
},
},
}
Notes:
- This applies only to DM channel creation (
/api/v4/channels/direct), not every Mattermost API call. - Retries use exponential backoff with jitter and apply to transient failures such as rate limits, 5xx responses, and network or timeout errors.
- 4xx client errors other than
429are treated as permanent and are not retried, even if reading the error response body fails. Diagnostics retain the HTTP status when the response body is unavailable.
Preview streaming
Mattermost streams thinking, tool activity, and partial reply text into a draft preview post that finalizes in place when the final answer is safe to send. In partial mode the preview updates on the same post id instead of spamming the channel with per-chunk messages. In block mode the preview rotates between completed text and tool-activity blocks, so earlier blocks stay visible as their own posts instead of being overwritten by the next one. Media/error finals cancel pending preview edits and use normal delivery instead of flushing a throwaway preview post.
Preview streaming is on by default in partial mode. Configure via channels.mattermost.streaming.mode (legacy scalar/boolean streaming values are migrated by apus doctor --fix):
{
channels: {
mattermost: {
streaming: { mode: "partial" }, // off | partial | block | progress
},
},
}
Streaming modes
- `partial` (default): one preview post that is edited as the reply grows, then finalized with the complete answer.
- `block` rotates the preview between completed text and tool-activity blocks, so each block stays visible as its own post instead of being overwritten in place. Parallel and consecutive tool updates share the current tool-activity post.
- `progress` shows a status preview while generating and only posts the final answer at completion.
- `off` disables preview streaming. With `streaming.block.enabled: true`, completed assistant blocks are still delivered as normal block replies (separate posts) rather than a single coalesced final post.
Streaming behavior notes
- If the stream cannot be finalized in place (for example the post was deleted mid-stream), Apus falls back to sending a fresh final post so the reply is never lost.
- Clearing a plan removes an otherwise empty preview; a replacement plan gets a fresh preview even when its text is unchanged. At turn completion, failed deletions of old previews are attempted once more without deleting the finalized reply. If deletion still fails, the old preview may remain; verbose logs include the cleanup failure.
- Thinking-only payloads are suppressed from channel posts, including text that arrives as a `> Thinking` blockquote. Set `/reasoning on` to see thinking in other surfaces; the Mattermost final post keeps the answer only.
- See [Streaming](/concepts/streaming#preview-streaming-modes) for the channel-mapping matrix.
Read channel history (message tool)
Use message action=read or the CLI to read posts from a channel that the configured Mattermost bot can access:
apus message read --channel mattermost --target channel:<channelId> --limit 5 --json
- Results follow Mattermost's ordered post list and include normalized
timestampMsandtimestampUtcfields. limitdefaults to 60 and is capped at Mattermost's maximum of 200. Use eitherbefore=<postId>orafter=<postId>for pagination; the two cursors cannot be combined.- Direct operator calls rely on Mattermost's channel membership and
read_channelpermission. A provider 403 remains a normal, visible tool error. - Delegated reads of the current Mattermost conversation are allowed for the current account. Cross-channel delegated reads additionally require the destination channel ID under
channels.mattermost.groups, a"*"groups entry, orgroupPolicy: "open". Cross-account and cross-channel DM reads fail closed. - History reads are disabled by default. Set
channels.mattermost.actions.messages: trueto enable them. Override the setting per account withchannels.mattermost.accounts.<id>.actions.messages. - These access rules apply to both the bundled plugin and the official plugin installed through npm or ClawHub. Delegated reads require the calling run and plugin registration to remain active.
Reactions (message tool)
- Use
message action=reactwithchannel=mattermost. messageIdis the Mattermost post id.emojiaccepts names likethumbsupor:+1:(colons are optional).- Set
remove=true(boolean) to remove a reaction. - Reaction add/remove events are forwarded as system events to the routed agent session, subject to the same DM/group policy checks as messages.
Examples:
message action=react channel=mattermost target=channel:<channelId> messageId=<postId> emoji=thumbsup
message action=react channel=mattermost target=channel:<channelId> messageId=<postId> emoji=thumbsup remove=true
Config:
channels.mattermost.actions.reactions: enable/disable reaction actions (default true).- Per-account override:
channels.mattermost.accounts.<id>.actions.reactions.
Interactive buttons (message tool)
Send messages with clickable buttons. When a user clicks a button, the agent receives the selection and can respond.
Buttons come from the semantic presentation payload (in normal agent replies and in message action=send). Apus renders value buttons as Mattermost interactive buttons, keeps URL buttons visible in the message text, and downgrades select menus to readable text.
The options an ask_user question offers are also rendered as buttons, and tapping one answers
that question directly. The question stays answerable by typing, and an option the Gateway does
not index stays in the prose instead: the "Other…" choice, and any prompt that asks more than one
question or whose question is multi-select, secret, or does not offer two to four distinct options.
Every other typed presentation action (command, callback, approval) stays readable text on
Mattermost rather than becoming a button: a click here reaches the agent as a message rather than
running the action, so a control would do something other than what it says.
message action=send channel=mattermost target=channel:<channelId> presentation={"blocks":[{"type":"buttons","buttons":[{"label":"Yes","value":"yes"},{"label":"No","value":"no"}]}]}
Presentation button fields:
label string
Display label (alias: text).
value string
Value sent back on click, used as the action ID (aliases: callback_data, callbackData). Required for a clickable button unless url is set.
url string
Link button; rendered as label: url text in the message body instead of an interactive button.
style "primary" | "secondary" | "success" | "danger"
Button style. Mattermost applies default styling to values it does not support.
To advertise button support in the agent system prompt, add inlineButtons to the channel capabilities:
{
channels: {
mattermost: {
capabilities: ["inlineButtons"],
},
},
}
When a user clicks a button:
Access check
The clicker must pass the same DM/group policy checks as a message sender; unauthorized clicks get an ephemeral notice and are ignored.
Buttons replaced with confirmation
All buttons are replaced with a confirmation line (e.g., "✓ **Yes** selected by @user").
Agent receives the selection
The agent receives the selection as an inbound message (plus a system event) and responds.
Implementation notes
- Button callbacks use HMAC-SHA256 verification (automatic, no config needed).
- The whole attachment block is replaced on click, so all buttons are removed together - partial removal is not possible.
- Action IDs containing hyphens or underscores are sanitized automatically (Mattermost routing limitation).
- Clicks whose `action_id` does not match an action on the original post are rejected with `403` ("Unknown action").
Config and reachability
- `channels.mattermost.capabilities`: array of capability strings. Add `"inlineButtons"` to enable the buttons tool description in the agent system prompt.
- `channels.mattermost.interactions.callbackBaseUrl`: optional external base URL for button callbacks (for example `https://gateway.example.com`). Use this when Mattermost cannot reach the gateway at its bind host directly.
- In multi-account setups, you can also set the same field under `channels.mattermost.accounts.<id>.interactions.callbackBaseUrl`.
- If `interactions.callbackBaseUrl` is omitted, Apus derives the callback URL from `gateway.customBindHost` + `gateway.port` (default 18789), then falls back to `http://localhost:<port>`. The callback path is `/mattermost/interactions/<accountId>`.
- Reachability rule: the button callback URL must be reachable from the Mattermost server. `localhost` only works when Mattermost and Apus run on the same host/network namespace.
- `channels.mattermost.interactions.allowedSourceIps`: source-IP allowlist for button callbacks. Without it, only loopback sources (`127.0.0.1`, `::1`) are accepted, so a remote Mattermost server must be allowlisted here or its clicks are rejected with `403`. Behind a reverse proxy, also set `gateway.trustedProxies` so the real client IP is derived from forwarded headers.
- If your callback target is private/tailnet/internal, add its host/domain to Mattermost `ServiceSettings.AllowedUntrustedInternalConnections`.
Direct API integration (external scripts)
External scripts and webhooks can post buttons directly via the Mattermost REST API instead of going through the agent's message tool. Prefer Apus's message tool. For direct integrations, import buildButtonAttachments from @openclaw/mattermost/api.js; if posting raw JSON, follow these rules:
Payload structure:
{
channel_id: "<channelId>",
message: "Choose an option:",
props: {
attachments: [
{
actions: [
{
id: "mybutton01", // alphanumeric only - see below
type: "button", // required, or clicks are silently ignored
name: "Approve", // display label
style: "primary", // optional: "default", "primary", "danger"
integration: {
url: "https://gateway.example.com/mattermost/interactions/default",
context: {
action_id: "mybutton01", // must match button id
action: "approve",
// ... any custom fields ...
_token: "<hmac>", // see HMAC section below
},
},
},
],
},
],
},
}
Critical rules
- Attachments go in
props.attachments, not top-levelattachments(silently ignored). - Every action needs
type: "button"- without it, clicks are swallowed silently. - Every action needs an
idfield - Mattermost ignores actions without IDs. - Action
idmust be alphanumeric only ([a-zA-Z0-9]). Hyphens and underscores break Mattermost's server-side action routing (returns 404). Strip them before use. context.action_idmust match the button'sid; the gateway rejects clicks whoseaction_iddoes not exist on the post.context.action_idis required - the interaction handler returns 400 without it.- The callback source IP must be allowed (see
interactions.allowedSourceIpsabove).
HMAC token generation
The gateway verifies button clicks with HMAC-SHA256. External scripts must generate tokens that match the gateway's verification logic:
Derive the secret from the bot token
`HMAC-SHA256(key="apus-mattermost-interactions", data=botToken)`, hex-encoded.
Build the context object
Build the context object with all fields **except** `_token`.
Serialize with sorted keys
Serialize with **recursively sorted keys** and **no spaces** (the gateway canonicalizes nested objects too and produces compact JSON).
Sign the payload
`HMAC-SHA256(key=secret, data=serializedContext)`
Add the token
Add the resulting hex digest as `_token` in the context.
Python example:
import hmac, hashlib, json
secret = hmac.new(
b"apus-mattermost-interactions",
bot_token.encode(), hashlib.sha256
).hexdigest()
ctx = {"action_id": "mybutton01", "action": "approve"}
payload = json.dumps(ctx, sort_keys=True, separators=(",", ":"))
token = hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
context = {**ctx, "_token": token}
Common HMAC pitfalls
- Python's `json.dumps` adds spaces by default (`{"key": "val"}`). Use `separators=(",", ":")` to match JavaScript's compact output (`{"key":"val"}`).
- Always sign **all** context fields (minus `_token`). The gateway strips `_token` then signs everything remaining. Signing a subset causes silent verification failure.
- Use `sort_keys=True` - the gateway sorts keys before signing, and Mattermost may reorder context fields when storing the payload.
- Derive the secret from the bot token (deterministic), not random bytes. The secret must be the same across the process that creates buttons and the gateway that verifies.
Directory adapter
The Mattermost plugin includes a directory adapter that resolves channel and user names via the Mattermost API. This enables #channel-name and @username targets in apus message send and cron/webhook deliveries.
No configuration is needed - the adapter uses the bot token from the account config.
Multi-account
Mattermost supports multiple accounts under channels.mattermost.accounts:
{
channels: {
mattermost: {
accounts: {
default: { name: "Primary", botToken: "mm-token", baseUrl: "https://chat.example.com" },
alerts: { name: "Alerts", botToken: "mm-token-2", baseUrl: "https://alerts.example.com" },
},
},
},
}
Account values override top-level fields; channels.mattermost.defaultAccount picks which account is used when none is specified.
Troubleshooting
No replies in channels
Ensure the bot is in the channel and mention it (oncall), use a trigger prefix (onchar), or set `chatmode: "onmessage"`.
Auth or multi-account errors
- Check the bot token, base URL, and whether the account is enabled.
- Multi-account issues: env vars only apply to the `default` account.
- Private/LAN Mattermost hosts need `network.dangerouslyAllowPrivateNetwork: true` (the SSRF guard blocks private IPs by default).
Native slash commands fail
- `Unauthorized: invalid command token.`: Apus did not accept the callback token. Typical causes:
- slash command registration failed or only partially completed at startup
- the callback is hitting the wrong gateway/account
- Mattermost still has old commands pointing at a previous callback target
- the gateway restarted without reactivating slash commands
- HTTP 429 under load: callbacks using an outgoing OAuth connection or a proxy that strips the `Authorization` header carry the command token only in the body. They share the bounded anonymous request pool and can be throttled when it is saturated. Preserve Mattermost's original `Authorization: Token` header through proxies so recognized command credentials receive separate capacity.
- If native slash commands stop working, check logs for `mattermost: failed to register slash commands` or `mattermost: native slash commands enabled but no commands could be registered`.
- If `callbackUrl` is omitted and logs warn that the callback resolved to a loopback URL like `http://localhost:18789/...`, that URL is probably only reachable when Mattermost runs on the same host/network namespace as Apus. Set an explicit externally reachable `commands.callbackUrl` instead.
Buttons issues
- Buttons appear as white boxes or not at all: the button data is malformed. Each presentation button needs a `label` and a `value` (buttons missing either are dropped).
- Buttons render but clicks do nothing: verify the gateway is reachable from the Mattermost server, the Mattermost server IP is included in `channels.mattermost.interactions.allowedSourceIps` (only loopback is accepted without it), and `ServiceSettings.AllowedUntrustedInternalConnections` includes the callback host for private targets.
- Buttons return 404 on click: the button `id` likely contains hyphens or underscores. Mattermost's action router breaks on non-alphanumeric IDs. Use `[a-zA-Z0-9]` only.
- Gateway logs `rejected callback source`: the click came from an IP outside `interactions.allowedSourceIps`. Allowlist the Mattermost server or your ingress, and set `gateway.trustedProxies` behind a reverse proxy.
- Gateway logs `invalid _token`: HMAC mismatch. Check that you sign all context fields (not a subset), use sorted keys, and use compact JSON (no spaces). See the HMAC section above.
- Gateway logs `missing _token in context`: the `_token` field is not in the button's context. Ensure it is included when building the integration payload.
- Gateway rejects the click with `Unknown action`: `context.action_id` does not match any action `id` on the post. Set both to the same sanitized value.
- Agent does not offer buttons: add `capabilities: ["inlineButtons"]` to the Mattermost channel config.
Related
- Channel routing - session routing for messages
- Channels Overview - all supported channels
- Groups - group chat behavior and mention gating
- Pairing - DM authentication and pairing flow
- Security - access model and hardening