I run OpenClaw self-hosted on Kubernetes with Mattermost as the primary chat surface. Over time, I've set up four separate AI agents — each with its own personality, workspace, and purpose — all running in one gateway process. Getting the routing right was non-trivial, so here's what I learned.
The Setup
Four agents, four Mattermost bots, one server:
- Jack — personal AI assistant (the default agent). Lives in most channels, handles everything from coding tasks to calendar management.
- Housie — household assistant for shared living. Has its own dedicated channel (
#housie-bot) and answers questions about house rules, chores, and shared logistics. - Lumi — relationship coach for me and my partner. Uses IFS, NVC, and radical honesty frameworks. Lives in
#relationship-couchand can be DM'd by either of us. - Ace — work-focused assistant for Futuresearch. Has its own channel (
#ace-futuresearch) and handles work-related tasks.
Each agent has:
- Its own Mattermost bot account (separate bot token, separate WebSocket connection)
- Its own OpenClaw workspace (
SOUL.md,AGENTS.md, memory files, skills) - Its own session store and model configuration
The Simple Case: One Bot, One Channel
Housie was easy. It has a dedicated bot account and a dedicated channel. The config:
{
agents: {
list: [
{ id: "main", workspace: "~/.openclaw/workspace" },
{ id: "home", name: "Housie", workspace: "~/.openclaw/workspace-home",
model: { primary: "anthropic/claude-sonnet-4-6" } },
],
},
channels: {
mattermost: {
accounts: {
default: { name: "Jack", botToken: "jack-token" },
housie: { name: "Housie", botToken: "housie-token" },
},
groups: {
"<housie-channel-id>": { accountId: "housie" },
},
},
},
bindings: [
{ agentId: "home", match: { channel: "mattermost", accountId: "housie" } },
],
}
The binding says: "everything on the housie Mattermost account → route to the home agent." Clean and simple.
The Tricky Case: Shared Teams, Multiple Bots
Lumi is where it got interesting. When multiple bots are members of the same Mattermost team, all their WebSocket connections receive every message in every channel on that team. So when someone posts in #relationship-couch:
- Lumi's WebSocket receives it ✓ (intended)
- Jack's WebSocket receives it ✗ (unintended)
- Housie's WebSocket receives it ✗ (unintended)
Without explicit routing, OpenClaw processes the message from whichever WebSocket picks it up. If Jack's arrives first and there's no binding overriding it, it falls through to the default agent — Jack responds instead of Lumi.
The Fix: Per-Account Peer Bindings
The solution is to add explicit peer-level bindings for every account that might receive messages in that channel:
{
channels: {
mattermost: {
accounts: {
default: { name: "Jack", botToken: "jack-token" },
housie: { name: "Housie", botToken: "housie-token" },
lumi: { name: "Lumi", botToken: "lumi-token",
dmPolicy: "allowlist",
allowFrom: ["<user-id-1>", "<user-id-2>"] },
},
groups: {
"<lumi-channel-id>": { requireMention: false, accountId: "lumi" },
},
},
},
bindings: [
// Housie: account-wide (simple case)
{ agentId: "home", match: { channel: "mattermost", accountId: "housie" } },
// Lumi: account-wide for its own bot
{ agentId: "lumi", match: { channel: "mattermost", accountId: "lumi" } },
// Lumi: peer-level overrides for OTHER bots seeing the same channel
{ agentId: "lumi", match: { channel: "mattermost", accountId: "default",
peer: { kind: "group", id: "<lumi-channel-id>" } } },
{ agentId: "lumi", match: { channel: "mattermost", accountId: "housie",
peer: { kind: "group", id: "<lumi-channel-id>" } } },
],
}
The last two bindings are the key insight. They tell OpenClaw: "even when Jack's or Housie's WebSocket picks up a message from this channel, route it to Lumi." OpenClaw's routing is most-specific-wins, and peer-level bindings (tier 1) beat accountId-only bindings (tier 6).
Adding Another Agent (Ace)
Once you understand the pattern, adding a fourth agent is straightforward. Ace gets its own bot account, channel, and binding:
{
channels: {
mattermost: {
accounts: {
// ...existing accounts...
ace: { name: "Ace", botToken: "ace-token",
dmPolicy: "allowlist", allowFrom: ["<your-user-id>"] },
},
groups: {
"<ace-channel-id>": { requireMention: false, accountId: "ace" },
},
},
},
bindings: [
// ...existing bindings...
{ agentId: "ace", match: { channel: "mattermost", accountId: "ace" } },
],
}
If Ace's bot is on a team where no other bots are members, you don't need peer bindings — only Ace's WebSocket will see messages in that channel. Peer bindings are only needed when multiple bots share a team.
Team Separation Strategy
You don't need all bots on the same team. In fact, keeping bots on separate teams simplifies routing by avoiding the multi-WebSocket problem entirely.
My setup:
- Artpolis (household team): Jack only
- YoMi (personal/work team): Housie, Lumi, Ace
Jack doesn't need peer bindings for Housie/Lumi/Ace channels because he's not on their team — his WebSocket never sees those messages. Peer bindings are only needed where teams overlap.
When to put bots on the same team:
- They need to search each other's channels
- They need cross-channel context
- Users expect to interact with multiple bots in the same team
When to separate:
- Bots serve completely different purposes
- You want simpler routing config
- No cross-channel needs
Mention Gating: Who Responds When
By default, you probably want bots to respond to every message in their dedicated channel, but only to @mentions elsewhere. This is controlled by chatmode and requireMention.
Bot responds to everything in its channel, mention-only elsewhere:
{
channels: {
mattermost: {
chatmode: "oncall", // global default: require mention
accounts: {
housie: { botToken: "...", chatmode: "oncall" },
},
groups: {
"<housie-channel-id>": {
accountId: "housie",
requireMention: false, // override: respond to everything here
},
},
},
},
}
Bot responds to everything everywhere (no mention needed):
{
channels: {
mattermost: {
chatmode: "onmessage", // no mention needed globally
},
},
}
Bot responds only when mentioned (everywhere):
{
channels: {
mattermost: {
chatmode: "oncall", // require mention globally
// no per-channel overrides
},
},
}
How the two layers interact
There are two layers of requireMention resolution:
-
Account-level — derived from
chatmode:chatmode: "onmessage"→requireMention: falsechatmode: "oncall"→requireMention: true
-
Group-level — per-channel
requireMentioningroups:- If set → wins over account-level (group config takes priority)
- If not set → falls back to account-level
The group-level setting always wins when present. This is how you get "mention-only globally, but respond to everything in my dedicated channel."
Gotcha: Derived Mention Patterns
When mentionPatterns is not configured, OpenClaw auto-derives patterns from the agent's identity.name. The derived regex is \b@?Name\b (case-insensitive) — meaning the @ prefix is optional.
So if your agent is named "Housie", any message containing the word "housie" (with or without @) counts as a mention. This is less restrictive than you might expect.
To require explicit @ mentions only:
{
agents: {
list: [{
id: "home",
groupChat: {
mentionPatterns: ["@housie"], // disables auto-derived pattern
},
}],
},
}
Gotcha: Hot-Reload vs Restart
OpenClaw's gateway watches openclaw.json and hot-reloads most config changes. However, when adding a new Mattermost account, a full gateway restart is required. Hot-reload updates the config but doesn't establish new WebSocket connections for new bot accounts. Routing will silently fail — messages get processed by the default agent instead.
# Inside the container:
kill -HUP 1
# Or via kubectl:
kubectl rollout restart deployment openclaw -n <namespace>
I spent a while debugging "why is Jack responding in Lumi's channel" before realizing this.
Gotcha: Stale Sessions
This one is subtle and will waste your time if you don't know about it. When a message gets routed to the wrong agent (e.g. before you fix bindings), OpenClaw creates a session under that agent. Even after fixing the config and restarting, the existing session keeps its old routing. The session was born under the wrong agent, and it stays there.
You need to delete the stale session:
# Find and remove entries containing your channel ID from:
~/.openclaw/agents/<agent-id>/sessions/sessions.json
After ANY routing or config change, always:
- Update
openclaw.json - Restart the gateway (
kill -HUP 1orkubectl rollout restart) - Delete stale sessions for affected channels
- Test — otherwise you're validating against cached old routing
Validating Your Setup
After any routing change, test all combinations before considering it done:
- DM each bot directly → does the correct agent respond?
- Post in each dedicated channel → does the right bot answer?
- Post in a shared channel with
@mention→ correct agent picks it up? - Post in a shared channel without
@mention→ no agent responds (ifoncall)? - Post in a channel where peer bindings exist → right agent, not the default?
Clean up test messages afterward so channels aren't cluttered.
Don't Remove Bots from Teams (to Fix Routing)
My first instinct when Jack responded in Lumi's channel was to remove Jack from that team. Don't do this to fix routing — the binding approach is the correct solution. Removing bots from teams breaks search, context, and cross-channel functionality.
That said, bots don't need to be on every team. Only add them to teams where they need visibility. See Team Separation Strategy above.
The Full Mental Model
Message arrives in #relationship-couch (YoMi team)
├── Lumi's WebSocket → accountId: "lumi" → binding match → agent: lumi ✓
├── Housie's WebSocket → accountId: "housie" → peer binding → agent: lumi ✓
└── Jack's WebSocket → not on this team → never sees it ✓
Message arrives in #general (Artpolis team)
└── Jack's WebSocket → accountId: "default" → chatmode: oncall
→ @jack mentioned? → yes → agent: main ✓
→ not mentioned? → ignored ✓
All paths lead to the intended agent (or silence). That's the goal.
FAQ
Q: Do I need peer bindings for every agent? A: Only if multiple bots share a Mattermost team. If a bot is the only one on its team, account-level bindings are sufficient. Peer bindings solve the "multiple WebSockets see the same message" problem.
Q: Why does my bot respond to messages without @mention?
A: Check two things: (1) Is chatmode set to "onmessage" globally or on the account? That disables mention requirements. (2) OpenClaw's auto-derived mention patterns match the agent's name without the @ prefix. So a message saying "hey housie" triggers a response even without @housie. Set explicit mentionPatterns: ["@housie"] to require the @.
Q: I changed the config but the old agent still responds. Why?
A: Stale sessions. OpenClaw caches routing in session objects. Delete the session from agents/<agent-id>/sessions/sessions.json and restart.
Q: Do I need to restart for every config change? A: Most changes hot-reload. The exception is new Mattermost bot accounts — those require a full restart because hot-reload doesn't create new WebSocket connections.
Q: Can I restrict who can DM a bot?
A: Yes. Set dmPolicy: "allowlist" and allowFrom: ["<user-id-1>", "<user-id-2>"] on the account. The bot will ignore DMs from anyone not on the list.
Q: How do I add a new agent from scratch?
A: The steps: (1) Create a bot in Mattermost → get the token. (2) Add the account in channels.mattermost.accounts. (3) Add the agent in agents.list with its own workspace. (4) Add a binding mapping the account to the agent. (5) Optionally add groups entries for dedicated channels. (6) Restart the gateway (new account = restart required). (7) Test all combinations.
Summary
| Scenario | Binding pattern |
|---|---|
| One bot = one agent (simple) | accountId match only |
| Multiple bots share a team | accountId match + peer bindings for each other account |
| DM routing | peer: { kind: "direct", id: "<user-id>" } per account |
| Bots on separate teams | accountId match only (no peer bindings needed) |
The key principle: if multiple bots can see a channel, you need a binding for each bot pointing to the intended agent. OpenClaw's routing is deterministic and most-specific-wins, but you have to be explicit about it.