add-mattermost · git:20260826.bf23e39 · 2026-08-26 · sha256 551d81336ef2f341

add-mattermost git:20260826.bf23e39B

Immutable. This exact content is served forever at /api/v1/blob/551d81336ef2f341.

---
name: add-mattermost
description: Add a self-hosted or cloud Mattermost bot channel through the Chat SDK bridge, reusing a local server when available and offering an evaluation server when none exists.
---

# Add Mattermost Channel

Adds Mattermost DMs, channels, threads, files, reactions, and interactive
approval cards. Messages arrive over Mattermost's WebSocket; card clicks return
to NanoClaw over an authenticated HTTP callback. Every step is safe to re-run.

## Discover the server first

Do this before installing the adapter or asking for a URL. The goal is to
reuse a healthy Mattermost the user already has and establish one canonical
base URL.

1. Check an existing `MATTERMOST_BASE_URL` in the current environment and
   NanoClaw env/config files. Do not print tokens or dump whole env files.
2. Probe likely local URLs, at least `http://localhost:8065` and
   `http://127.0.0.1:8065`, using `GET /api/v4/system/ping`. A listening port
   alone is not evidence that the service is Mattermost.
3. Inspect Docker/Compose for Mattermost containers. If a matching container
   exists but is stopped, offer to start it; do not start or recreate it
   without the user's approval.
4. If a healthy server is found, show its URL and ask whether to use it, enter
   another URL, or create the bundled local evaluation/development server.
   Never select a detected server on the user's behalf. Treat localhost and
   127.0.0.1 endpoints for the same container as one detection.
5. If nothing local is found, ask whether the user has a remote Mattermost.
   If not, offer the local evaluation installation in
   [LOCAL_SERVER.md](LOCAL_SERVER.md). Read that file only for local server
   discovery, repair, or installation.

Set `MATTERMOST_BASE_URL` to the chosen canonical URL (scheme included, no
trailing slash), then use that exact hostname in browser/Desktop setup. Do not
silently install Mattermost: it runs containers, binds a port, and persists
data, so show what will be created and get approval first.

## Apply

### 1. Detect the server

Probe a configured URL and the conventional local endpoints. Detection is only
a suggestion: always let the operator confirm it, enter another URL, or create
the bundled local evaluation/development server.

```nc:run capture:discovery=.discovery,detected_url=.base_url,detected_config_access=.config_access,detected_container=.mattermost_container effect:fetch
node .claude/skills/add-mattermost/scripts/discover-server.mjs
```

```nc:operator when:discovery=found
A healthy Mattermost server was detected at {{detected_url}}. Confirm whether to use it; detection never selects a server on your behalf.
```

```nc:prompt server_choice when:discovery=found normalize:lower validate:^(use|enter|create)$
Enter `use` for {{detected_url}}, `enter` to provide another Mattermost URL, or `create` for a new local evaluation/development server.
```

```nc:run capture:base_url=.base_url,config_access=.config_access,mattermost_container=.mattermost_container effect:fetch when:server_choice=use
node .claude/skills/add-mattermost/scripts/select-server.mjs use "{{detected_url}}" "{{detected_config_access}}" "{{detected_container}}"
```

```nc:prompt entered_url when:server_choice=enter normalize:rstrip-slash validate:^https?://(?:[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?|\[[0-9A-Fa-f:.]+\])(?::[0-9]{1,5})?(?:/[A-Za-z0-9._~%+-]+)*$
Mattermost base URL including the scheme, such as `https://mattermost.example.com`.
```

```nc:run capture:base_url=.base_url,config_access=.config_access,mattermost_container=.mattermost_container effect:fetch when:server_choice=enter
node .claude/skills/add-mattermost/scripts/select-server.mjs enter "{{entered_url}}"
```

```nc:run capture:create_requested when:server_choice=create
printf 'yes\n'
```

```nc:operator when:discovery=none
No healthy configured or local Mattermost server was detected. Choose whether to enter an existing remote server URL or create the bundled local evaluation/development server.
```

```nc:prompt no_server_choice when:discovery=none normalize:lower validate:^(enter|create)$
Enter `enter` to provide a Mattermost URL or `create` for a new local evaluation/development server.
```

```nc:prompt entered_url_new when:no_server_choice=enter normalize:rstrip-slash validate:^https?://(?:[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?|\[[0-9A-Fa-f:.]+\])(?::[0-9]{1,5})?(?:/[A-Za-z0-9._~%+-]+)*$
Mattermost base URL including the scheme, such as `https://mattermost.example.com`.
```

```nc:run capture:base_url=.base_url,config_access=.config_access,mattermost_container=.mattermost_container effect:fetch when:no_server_choice=enter
node .claude/skills/add-mattermost/scripts/select-server.mjs enter "{{entered_url_new}}"
```

```nc:run capture:create_requested when:no_server_choice=create
printf 'yes\n'
```

Tell the operator exactly what local creation does, then obtain approval:

```nc:operator when:create_requested=yes
Creating the local evaluation/development server will start Mattermost Team Edition and PostgreSQL containers, create a Docker network and named persistent volumes, write reproducible files under .nanoclaw/mattermost, and bind 127.0.0.1:8065. Docker and Compose must be available and port 8065 must be free. If another server uses that port, this installer will stop without changing it.
```

```nc:prompt local_install_approval when:create_requested=yes normalize:lower validate:^install$
Enter `install` to approve creating and starting those local resources.
```

After approval, verify prerequisites, create the reproducible stack, and wait
at most 60 seconds for Mattermost. On failure, show bounded service logs and
stop; do not claim the server exists.

```nc:run effect:external when:local_install_approval=install
docker info >/dev/null
docker compose version >/dev/null
node -e 'const net=require("node:net");const s=net.createServer();s.once("error",()=>process.exit(1));s.listen(8065,"127.0.0.1",()=>s.close())'
mkdir -p .nanoclaw/mattermost
cp .claude/skills/add-mattermost/assets/compose.yml .nanoclaw/mattermost/compose.yml
umask 077
test -f .nanoclaw/mattermost/.env || printf 'MATTERMOST_DB_PASSWORD=%s\n' "$(openssl rand -hex 24)" > .nanoclaw/mattermost/.env
docker compose -f .nanoclaw/mattermost/compose.yml up -d
for attempt in $(seq 1 30); do curl -fsS --connect-timeout 1 --max-time 1 http://localhost:8065/api/v4/system/ping >/dev/null && exit 0; sleep 1; done
docker compose -f .nanoclaw/mattermost/compose.yml logs --tail 100 mattermost
exit 1
```

```nc:run capture:base_url=.base_url,config_access=.config_access,mattermost_container=.mattermost_container effect:fetch when:local_install_approval=install
node .claude/skills/add-mattermost/scripts/select-server.mjs create
```

### 2. Align the server's canonical URL

Mattermost Desktop opens its WebSocket with the configured server URL as the
Origin. Before installing the adapter, make `ServiceSettings.SiteURL` exactly
match `{{base_url}}`. Keep `ServiceSettings.WebsocketURL` blank; it is not the
fix for an origin mismatch. Do not broaden `ServiceSettings.AllowCorsFrom`.

When discovery found host-local `mmctl`, ask before changing the server:

```nc:prompt site_url_action normalize:lower validate:^(set|already)$ when:config_access=host
Enter `set` to set Mattermost's canonical SiteURL to {{base_url}} and keep WebsocketURL blank, or `already` only if those settings are already correct.
```

```nc:run effect:external when:site_url_action=set
mmctl config set ServiceSettings.SiteURL "{{base_url}}" --local
mmctl config set ServiceSettings.WebsocketURL "" --local
```

When discovery found `mmctl` inside a local Mattermost container, ask before
changing it there:

```nc:prompt site_url_action_docker normalize:lower validate:^(set|already)$ when:config_access=docker
Enter `set` to configure {{mattermost_container}} with canonical SiteURL {{base_url}} and a blank WebsocketURL, or `already` only if those settings are already correct.
```

```nc:run effect:external when:site_url_action_docker=set
docker exec "{{mattermost_container}}" mmctl config set ServiceSettings.SiteURL "{{base_url}}" --local
docker exec "{{mattermost_container}}" mmctl config set ServiceSettings.WebsocketURL "" --local
```

If local configuration access is unavailable, tell the operator:

```nc:operator when:config_access=unavailable
Set Mattermost ServiceSettings.SiteURL to exactly {{base_url}} and leave ServiceSettings.WebsocketURL blank before continuing. Either sign in as a System Admin with mmctl and run `mmctl config set ServiceSettings.SiteURL "{{base_url}}"` plus `mmctl config set ServiceSettings.WebsocketURL ""`, or use System Console → Environment → Web Server. Do not work around the mismatch with a broad ServiceSettings.AllowCorsFrom value.
```

```nc:prompt site_url_ready normalize:lower validate:^ready$ when:config_access=unavailable
Enter `ready` after the Mattermost settings above are saved.
```

Verify the effective client configuration through Mattermost's public client
config endpoint. This must print `{{base_url}}` followed by a blank line:

```nc:run effect:fetch
curl -fsS "{{base_url}}/api/v4/config/client?format=old" | jq -er --arg url "{{base_url}}" '(.SiteURL == $url and (.WebsocketURL // "") == "") as $ok | if $ok then .SiteURL, (.WebsocketURL // "") else error("Mattermost SiteURL/WebsocketURL mismatch") end'
```

### 3. Copy and register the channel

Copy the canonical adapter and registration test from the `channels` branch.

```nc:copy from-branch:channels
src/channels/mattermost.ts
src/channels/mattermost-registration.test.ts
src/channels/mattermost-adapter/adapter.ts
src/channels/mattermost-adapter/adapter.test.ts
src/channels/mattermost-adapter/format.ts
src/channels/mattermost-adapter/index.ts
src/channels/mattermost-adapter/rest.ts
src/channels/mattermost-adapter/thread-id.ts
src/channels/mattermost-adapter/types.ts
src/channels/mattermost-adapter/websocket.ts
src/channels/mattermost-adapter/websocket.test.ts
```

Append the channel's single reach-in to the barrel, skipping it if present.

```nc:append to:src/channels/index.ts
import './mattermost.js';
```

Remove the unscoped `chat-adapter-mattermost` package when it is installed.
Nothing in this repository imports it: it is typosquat-shaped against the
scoped `@chat-adapter` family, so any copy in `package.json` is stale or
mistaken and would sit beside the audited implementation copied from the
`channels` branch.

```nc:run
if node -e "const p=require('./package.json'); process.exit(p.dependencies?.['chat-adapter-mattermost'] ? 0 : 1)"; then pnpm remove chat-adapter-mattermost; fi
```

Install the vendored adapter's direct WebSocket dependencies at the exact
supported versions.

```nc:dep
ws@8.21.3
@types/ws@8.18.1
```

### 4. Create and authenticate the bot

Tell the operator:

```nc:operator
Create a dedicated Mattermost bot:
1. As a System Admin, open System Console → Integrations → Bot Accounts and turn on Enable Bot Account Creation if it is disabled. This setting only permits bot creation; it is not where bots are created.
2. Return to the Mattermost workspace, open the Product menu → Integrations → Bot Accounts, select Add Bot Account, and create a bot such as `nanoclaw`.
3. Copy the access token shown after creation.
4. Add the bot to every team and channel where it should receive messages. Bots do not join teams or channels automatically.
5. Keep the token private. If it is lost, create a new token and deactivate the obsolete one after replacement.
```

```nc:prompt bot_token secret normalize:trim validate:^[A-Za-z0-9_-]{20,}$
Mattermost bot access token (20 or more letters, digits, underscores, or hyphens).
```

Confirm the credential and capture the bot identity. A failure means the URL,
token, or bot-account status is wrong.

```nc:run capture:bot_user_id=.id,bot_username=.username effect:fetch
curl -sf "{{base_url}}/api/v4/users/me" -H "Authorization: Bearer {{bot_token}}"
```

### 5. Configure authenticated card callbacks

Approvals require Mattermost itself—not the browser—to reach NanoClaw. Ask for
a URL routable from the Mattermost server. It may be NanoClaw's base URL or the
full `/webhook/mattermost` route; the adapter normalizes either form.

```nc:prompt callback_url normalize:rstrip-slash validate:^https?://.+
Callback URL reachable from Mattermost, such as `https://nanoclaw.example.com` or `http://host.docker.internal:3000/webhook/mattermost`.
```

Mattermost does not sign action callbacks. Generate a random shared secret for
the server-only callback context.

```nc:run capture:callback_secret effect:external validate:^[a-f0-9]{64}$
openssl rand -hex 32
```

Update the selected canonical URL on every run so choosing another server
corrects an existing installation. Keep existing credentials unchanged.

```nc:run effect:external
pnpm exec tsx setup/index.ts --step set-env -- --key MATTERMOST_BASE_URL --value "{{base_url}}"
```

Store the remaining channel configuration. Existing credential keys remain
unchanged on a re-run.

```nc:env-set
MATTERMOST_BASE_URL={{base_url}}
MATTERMOST_BOT_TOKEN={{bot_token}}
MATTERMOST_CALLBACK_URL={{callback_url}}
MATTERMOST_CALLBACK_SECRET={{callback_secret}}
```

Tell the operator:

```nc:operator
From the Mattermost server, verify the callback host is reachable. For a private host or Docker bridge name, add that hostname or IP under System Console → Environment → Developer → Allow untrusted internal connections. Use a publicly trusted HTTPS certificate in production.
```

### 6. Resolve the owner's DM

Ask for the Mattermost username that will own this NanoClaw installation.

```nc:prompt owner_username normalize:lower validate:^[a-z0-9][a-z0-9._-]{0,63}$
Your Mattermost username, without `@`.
```

Resolve that user and open the DM shared with the bot.

```nc:run capture:owner_user_id=.id effect:fetch
curl -sf "{{base_url}}/api/v4/users/username/{{owner_username}}" -H "Authorization: Bearer {{bot_token}}"
```

```nc:run capture:platform_id effect:fetch validate:^mattermost:[a-z0-9]{26}$
curl -sf -X POST "{{base_url}}/api/v4/channels/direct" -H "Authorization: Bearer {{bot_token}}" -H "Content-Type: application/json" -d '["{{owner_user_id}}","{{bot_user_id}}"]' | jq -er '"mattermost:" + .id'
```

The resolved `platform_id` and `owner_username` are used by
`/init-first-agent`. If an owner exists, use `/manage-channels` instead.

### 7. Build, test, and restart

Build the composed host to guard the typed Chat SDK bridge call and dependency.

```nc:run effect:build
pnpm run build
```

Run the registration test through the real channel barrel and the focused
adapter regressions installed beside the implementation.

```nc:run effect:test
pnpm exec vitest run src/channels/mattermost-registration.test.ts src/channels/mattermost-adapter/adapter.test.ts src/channels/mattermost-adapter/websocket.test.ts
```

Restart NanoClaw so the channel and credentials load.

```nc:run effect:restart
bash setup/lib/restart.sh
```

## Next steps

For a first channel, continue with `/init-first-agent` using `mattermost`,
`{{platform_id}}`, and `{{owner_username}}`. Otherwise run `/manage-channels`.

Send the bot a DM and mention it in a joined channel. The first mention in an
unwired channel sends an approval card to the owner's bot DM. Approve it there;
NanoClaw replays the held message after creating the wiring.

Click a real approval card to verify callbacks. Success replaces the buttons
with the chosen result. An unsigned probe must return `401`:

```bash
curl -sS -o /dev/null -w '%{http_code}\n' -X POST \
  -H 'content-type: application/json' -d '{}' \
  http://<nanoclaw-host>:3000/webhook/mattermost
```

## Channel information

- **type:** `mattermost`
- **platform ID:** `mattermost:<channel-id>` for channels and DMs
- **threads:** channel posts use optional Mattermost reply roots
- **group trigger:** mention-sticky, scoped per thread
- **DM trigger:** every message
- **unknown channels:** request owner approval
- **transport:** WebSocket inbound, REST outbound, HTTP action callbacks

## Troubleshooting

**The token check returns 401.** The token is stale, belongs to a deactivated
bot, or was pasted incorrectly. Create a replacement token and deactivate the
old token after the replacement works.

**The bot ignores a channel.** Add it to that team and channel. Membership
changes are observed, but restarting NanoClaw forces a fresh subscription.

**A new channel gets no immediate reply.** Check the owner's DM with the bot.
NanoClaw holds the first message behind a channel-approval card and deduplicates
later mentions until that card is resolved.

**Desktop messages appear only after a manual refresh.** This is usually the
Desktop client's WebSocket origin being rejected. Keep the Desktop server URL,
`MATTERMOST_BASE_URL`, and Mattermost `ServiceSettings.SiteURL` on the same
canonical hostname. Leave `ServiceSettings.WebsocketURL` blank, verify the
effective values through `/api/v4/config/client?format=old`, and check server
logs for `request origin not allowed`. Do not mask a canonical-URL mismatch by
broadening `ServiceSettings.AllowCorsFrom`. For Compose installations, persist
SiteURL declaratively so container recreation does not discard the fix.

**Cards render but clicks do nothing.** From the Mattermost server, POST to the
callback URL. A `401` proves the path reaches NanoClaw; timeout or refusal means
routing or firewall failure. Mattermost logs report blocked hosts and TLS errors.

**The adapter repeatedly reconnects.** Confirm `/api/v4/websocket` supports
WebSocket upgrades through every reverse proxy and that idle connections live
longer than the adapter heartbeat.

**Messages arrive but no agent runs.** Inspect `ncl dropped-messages list` and
`ncl wirings list`. `no_agent_wired` means approval is pending or no wiring was
created; it is not an adapter failure.