docs/channels/mattermost.md
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).
Details: Plugins
```json5
{
channels: {
mattermost: {
enabled: true,
botToken: "mm-token",
baseUrl: "https://chat.example.com",
dmPolicy: "pairing",
},
},
}
```
Non-interactive alternative:
```bash
openclaw channels add --channel mattermost --bot-token <token> --http-url https://chat.example.com
```
Native slash commands are opt-in. When enabled, OpenClaw 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>.
- Do not set `callbackUrl` to `localhost` unless Mattermost runs on the same host/network namespace as OpenClaw.
- Do not set `callbackUrl` to your Mattermost base URL unless that URL reverse-proxies `/api/channels/mattermost/command` to OpenClaw.
- A quick check is `curl https://<gateway-host>/api/channels/mattermost/command`; a GET should return `405 Method Not Allowed` from OpenClaw, not `404`.
Use host/domain entries, not full URLs.
- Good: `gateway.tailnet-name.ts.net`
- Bad: `https://gateway.tailnet-name.ts.net`
Set these on the gateway host if you prefer env vars:
MATTERMOST_BOT_TOKEN=...MATTERMOST_URL=https://chat.example.comMATTERMOST_URL cannot be set from a workspace .env; see Workspace .env files.
</Note>
Mattermost responds to DMs automatically. Channel behavior is controlled by chatmode:
Config example:
{
channels: {
mattermost: {
chatmode: "onchar",
oncharPrefixes: [">", "!"], // default
},
},
}
Notes:
onchar still responds to explicit @mentions.channels.mattermost.requireMention is still honored, but chatmode is preferred. Per-channel groups.<channelId>.requireMention settings win over both.onchar prefix, 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.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.all and batched: same behavior as first for Mattermost today, because once Mattermost has a thread root, follow-up chunks and media continue in that same thread.{
channels: {
mattermost: {
replyToMode: "all",
},
},
}
Thread-scoped sessions use the triggering post id as the thread root.
channels.mattermost.dmPolicy = "pairing" (unknown senders get a pairing code). Other values: allowlist, open, disabled.openclaw pairing list mattermostopenclaw pairing approve mattermost <CODE>channels.mattermost.dmPolicy="open" plus channels.mattermost.allowFrom=["*"] (the config schema enforces the wildcard).channels.mattermost.allowFrom accepts user ids (recommended) and accessGroup:<name> entries. See Access groups.channels.mattermost.groupPolicy = "allowlist" (mention-gated).channels.mattermost.groupAllowFrom (user IDs recommended).channels.mattermost.groupAllowFrom accepts accessGroup:<name> entries. See Access groups.channels.mattermost.groups.<channelId>.requireMention or channels.mattermost.groups["*"].requireMention for a default.@username matching is mutable and only enabled when channels.mattermost.dangerouslyAllowNameMatching: true.channels.mattermost.groupPolicy="open" (mention-gated).channels.mattermost.groupPolicy, then channels.defaults.groupPolicy, then "allowlist".channels.mattermost section is completely missing, runtime fails closed to groupPolicy="allowlist" for group checks (even if channels.defaults.groupPolicy is set) and logs a one-time warning.Example:
{
channels: {
mattermost: {
groupPolicy: "open",
groups: {
"*": { requireMention: true },
"team-channel-id": { requireMention: false },
},
},
},
}
Use these target formats with openclaw 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.
<Warning> Bare opaque IDs (like `64ifufp...`) are **ambiguous** in Mattermost (user ID vs channel ID).OpenClaw resolves them user-first:
GET /api/v4/users/<id> succeeds), OpenClaw sends a DM by resolving the direct channel via /api/v4/channels/direct.If you need deterministic behavior, always use the explicit prefixes (user:<id> / channel:<id>).
</Warning>
When OpenClaw 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:
/api/v4/channels/direct), not every Mattermost API call.429 are treated as permanent and are not retried.Mattermost streams thinking, tool activity, and partial reply text into a single draft preview post that finalizes in place when the final answer is safe to send. The preview updates on the same post id instead of spamming the channel with per-chunk messages. 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 (a mode string, boolean, or an object like { mode: "progress" }):
{
channels: {
mattermost: {
streaming: "partial", // off | partial | block | progress
},
},
}
message action=react with channel=mattermost.messageId is the Mattermost post id.emoji accepts names like thumbsup or :+1: (colons are optional).remove=true (boolean) to remove a reaction.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).channels.mattermost.accounts.<id>.actions.reactions.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). OpenClaw renders value buttons as Mattermost interactive buttons, keeps URL buttons visible in the message text, and downgrades select menus to readable text.
message action=send channel=mattermost target=channel:<channelId> presentation={"blocks":[{"type":"buttons","buttons":[{"label":"Yes","value":"yes"},{"label":"No","value":"no"}]}]}
Presentation button fields:
<ParamField path="label" type="string" required> Display label (alias: `text`). </ParamField> <ParamField path="value" type="string"> Value sent back on click, used as the action ID (aliases: `callback_data`, `callbackData`). Required for a clickable button unless `url` is set. </ParamField> <ParamField path="url" type="string"> Link button; rendered as `label: url` text in the message body instead of an interactive button. </ParamField> <ParamField path="style" type='"primary" | "secondary" | "success" | "danger"'> Button style. Mattermost applies default styling to values it does not support. </ParamField>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:
<Steps> <Step title="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. </Step> <Step title="Buttons replaced with confirmation"> All buttons are replaced with a confirmation line (e.g., "✓ **Yes** selected by @user"). </Step> <Step title="Agent receives the selection"> The agent receives the selection as an inbound message (plus a system event) and responds. </Step> </Steps> <AccordionGroup> <Accordion title="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"). </Accordion> <Accordion title="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, OpenClaw 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 OpenClaw 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`. </Accordion> </AccordionGroup>External scripts and webhooks can post buttons directly via the Mattermost REST API instead of going through the agent's message tool. Use buildButtonAttachments() from the plugin when possible; 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
},
},
},
],
},
],
},
}
props.attachments, not top-level attachments (silently ignored).type: "button" - without it, clicks are swallowed silently.id field - Mattermost ignores actions without IDs.id must 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_id must match the button's id; the gateway rejects clicks whose action_id does not exist on the post.context.action_id is required - the interaction handler returns 400 without it.interactions.allowedSourceIps above).HMAC token generation
The gateway verifies button clicks with HMAC-SHA256. External scripts must generate tokens that match the gateway's verification logic:
<Steps> <Step title="Derive the secret from the bot token"> `HMAC-SHA256(key="openclaw-mattermost-interactions", data=botToken)`, hex-encoded. </Step> <Step title="Build the context object"> Build the context object with all fields **except** `_token`. </Step> <Step title="Serialize with sorted keys"> Serialize with **recursively sorted keys** and **no spaces** (the gateway canonicalizes nested objects too and produces compact JSON). </Step> <Step title="Sign the payload"> `HMAC-SHA256(key=secret, data=serializedContext)` </Step> <Step title="Add the token"> Add the resulting hex digest as `_token` in the context. </Step> </Steps>Python example:
import hmac, hashlib, json
secret = hmac.new(
b"openclaw-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}
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 openclaw message send and cron/webhook deliveries.
No configuration is needed - the adapter uses the bot token from the account config.
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.