OpenClaw Architecture
OpenClaw puts channels, sessions, and devices behind one Gateway daemon. The agent does not speak Telegram or WhatsApp; it speaks the Gateway’s typed WebSocket. Channel plugins are ingress, not a second runtime.
One Gateway maps to one trust boundary. Extra channels, agents, and nodes stack in the same process. Split the Gateway when operators do not share trust, not when a second chat app appears.
The current contract is Gateway architecture: one long-lived Gateway owns every messaging surface; control-plane clients and nodes share that WebSocket. Source: openclaw/openclaw. Install path, loopback, pairing, and allowlists live in Best Practices, not here.
The hub is the daemon
Each channel talks its own dialect. WhatsApp is Baileys over a linked WhatsApp Web session. Telegram is grammY, long polling by default. Slack, Discord, Signal, iMessage, WebChat, and the rest of the channel plugins follow the same rule: the plugin translates platform events into an internal envelope; the socket and reconnect loop stay in the daemon.
channels / WebChat Clients (operator) Nodes (role: node)
WhatsApp · Telegram CLI · TUI · Control UI macOS / iOS / Android
Slack · Discord · … macOS app · automations headless node host
\ | /
\ | /
v v v
┌────────────────────────────────────────────────────────┐
│ Gateway daemon ws+http 127.0.0.1:18789 │
│ provider sockets · session SQLite · bindings │
│ typed WS RPC · cron · canvas HTTP · node.invoke │
└────────────────────────────┬───────────────────────────┘
│
v
Agent runtime (embedded)
workspace · model loop · tools
MEMORY.md / memory/*.mdThose names are from current docs. Nothing named “Message Router” is a separate process. The FAQ path is literal:
Telegram → Gateway → Agent → node.* → Node → Gateway → TelegramNodes never see inbound provider traffic. The channel lands on the Gateway, the model runs there, and node.invoke fires only when a device command is required.
What the daemon owns
The Gateway is the always-on process. Default: one instance per host, bound to 127.0.0.1:18789. That port is multiplexed:
- WebSocket: control-plane RPC and node transport
- HTTP: Control UI, hooks,
/__openclaw__/canvas/,/__openclaw__/a2ui/, plus OpenAI-compatible/v1/modelsand friends
The control plane is still WS. HTTP existing does not mean channels are polled over REST. Telegram long polling is an implementation detail inside the plugin, inside this process.
WebChat is not a second protocol. It is an internal channel on the same Gateway WS (chat.history / chat.send). Control UI HTTP is the shell. Remotely it shares the SSH or Tailscale tunnel with CLI and nodes.
Channel plugins may be bundled, official, or external. The process boundary does not change. Telegram is bundled today; WhatsApp is an on-demand official plugin. The Gateway is still the only process that holds the linked session. Two processes fighting over one Baileys session is an architectural forbid, not an ops preference.
Invariants from the architecture page:
- Exactly one Gateway opens a Baileys / WhatsApp session on a given host.
- Inbound WS frames are schema-validated (TypeBox). The first client frame must be
connect; anything else is a hard close. - Events are not replayed. On a sequence gap, the client refreshes.
- Cron runs inside the Gateway process. No daemon, no schedule.
Channels are inbound messaging. Model providers are outbound inference. Both are configured on the same daemon, as different planes. Text from Telegram becomes a session first; the agent loop then runs one serialized turn per session key. Anthropic, OpenAI, or a local model is only called inside that turn. Later messages on that session go through a queue (steer / followup / collect / interrupt) so two channels cannot interleave one transcript. Swap a channel plugin and the loop stays the same.
hello-ok.features.methods is a conservative discovery list, not a generated dump of every RPC. Methods such as push.test and sessions.usage can exist and still be omitted from discovery. Clients negotiate protocol version (currently 4). Do not treat the feature list as the full SDK surface.
Documented inspection (not re-run on the machine writing this):
openclaw gateway status
openclaw channels status --probe
openclaw agents list --bindings
openclaw sessions --jsongateway status reports both supervisor liveness and whether the CLI can actually complete a WebSocket probe. Channel probe is what proves ingress.
Wire protocol: three frames, three roles
The Gateway protocol is the single control plane and node transport. Current protocol version is 4. The server emits connect.challenge; the client’s first frame must be connect; success is hello-ok with features, snapshot, policy, and auth.
{ "type": "req", "id": "r1", "method": "health", "params": {} }
{ "type": "res", "id": "r1", "ok": true, "payload": { "ok": true } }
{ "type": "event", "event": "tick", "payload": { "ts": 1730000000 }, "seq": 12 }Side-effecting methods (send, agent, chat.send) require an idempotencyKey. An agent run is two-stage: an accepted ack, streamed event:agent frames, then a final res:agent. From the docs:
connect declares a role:
| role | What it is | What it is not |
|---|---|---|
operator |
CLI, TUI, Control UI, macOS control surface | Not camera / system.run |
node |
Device capability host: camera, screen, location, canvas, system.run |
Not a second Gateway; no channel sockets |
worker |
Cloud execution host on the closed /__openclaw__/worker protocol |
Not general operator RPC |
Nodes also declare caps / commands / permissions at connect. The Gateway treats those as claims and enforces server-side allowlists plus pairing. Pairing is device-based, not account-based.
Do not implement a custom “agent WebSocket server.” The bundled agent runtime is embedded in the Gateway. What gets written is an operator client, or an agent’s workspace and bindings.
Session is routing. Memory is Markdown on disk.
Session state is owned by the Gateway. UIs query it; they are not the source of truth. Default store:
~/.openclaw/agents/<agentId>/agent/openclaw-agent.sqliteDefaults:
| Source | Session |
|---|---|
| DMs | Collapse into that agent’s main session (agent:<id>:main) |
| Groups / rooms | Isolated per group |
| Cron | Fresh session per run |
| Webhooks | Isolated per hook |
A session key is a routing coordinate, not a filename. Default DMs collapse to agent:main:main. Groups add channel plus peer. Slack and Discord threads append :thread:; Telegram forum topics embed :topic:. A binding picks an agent only after the channel has already admitted the message. Pairing, allowlists, and mention gates are a different layer; not this post.
session.scope: "global" still does not merge two agents’ conversations. The shared key global keeps accounting on the agent the route selected.
If several people can DM the same bot, session.dmScope: "main" puts Alice’s private context in Bob’s thread. That is a session-key problem, not a bad MEMORY.md. The documented isolation:
{
session: {
dmScope: "per-channel-peer",
},
}Memory is Markdown in the agent workspace (default ~/.openclaw/workspace):
MEMORY.md: curated long-term facts, injected at session startmemory/YYYY-MM-DD.md: daily notes, recalled by search, not stuffed into every promptUSER.md/SOUL.md/AGENTS.md: persona and preferences; also workspace files, not Gateway SQLite
The cut:
- Session = this conversation’s transcript and routing key. Bounded by the model window; compaction shrinks it. Gateway-owned.
- Memory = notes that should survive a
/new. The model remembers what was written to disk. Workspace-owned. sessionKey, labels, and bindings pick which brain and which transcript. They are not an authorization boundary. High-impact tools, browser state, credentials, and session files still share the host trust surface.
memory.search.rememberAcrossConversations is optional retrieval across private chats. It does not merge transcripts. With default groupScope: "per-group", groups stay out of that recall in both directions.
Nodes are not clients
Nodes and clients share the WS server and nothing else about role.
- Client / operator:
health,status,send,agent,sessions.*; subscribe totick/presence/agent. One WS connection per client. - Node:
role: "node". macOS / iOS / Android / headless expose device commands. The macOS menu-bar app is the node on that Mac; do not start a second CLI node beside it.
A remote Gateway plus a laptop that executes is a supported shape: the Gateway receives messages, runs the model, and forwards exec host=node; the node host runs system.run locally. No separate TCP bridge. Canvas rides the same line: the daemon serves /__openclaw__/canvas/ on that port, and a macOS node presents it with canvas.*. Not a second static-file host.
Device pairing authenticates the handshake. node.pair.* approves the command surface. Two gates.
What the agent implements vs what the Gateway owns
In multi-agent terms, one agent is one brain: workspace, agentDir, its own SQLite session store. One Gateway process can host many. Bindings dispatch inbound messages by (channel, accountId, peer). The single-agent default is agentId=main, main key agent:main:main.
| Surface | Gateway owns | Agent implements / configures |
|---|---|---|
| Channel sockets, reconnect, account login | yes | no |
| Session keys, transcripts, compaction | yes | no |
WS protocol, pairing, node.invoke |
yes | no |
| Cron / heartbeat scheduling | yes | no |
| Canvas / A2UI HTTP | yes | no |
AGENTS.md / SOUL.md / MEMORY.md |
no | yes |
| Model pick, skills snapshot, tool calls | loop is embedded in the daemon | policy and workspace are per agent |
| Hard tool boundary (allowlist, sandbox, exec approval) | policy lives on the Gateway | per-agent rules may only tighten |
“Write an agent that implements the Gateway WS API” mixes the operator client with the agent runtime. Operator clients speak WS. The agent is workspace plus the model loop; the loop already lives in the daemon.
Multiple agents on one Gateway are the cheap, default setup. The FAQ is explicit: split hosts only for hard isolation or operators who should not trust each other.
Keep / skip
Keep one Gateway when:
- Operators share a trust boundary: a personal assistant, or a small team that already trusts each other. A shared Gateway is a first-class deployment: sessions carry a creator and an assignable owner; Control UI shows who is viewing and typing. That is multi-user inside one trust domain, not multi-tenant isolation.
- The thing to unify is channels and devices, not tenants. WhatsApp + Telegram + Discord on one daemon is the design
- A second machine needs camera /
system.run: add a node, not a second Gateway - A second persona is required: add
agents.entriesplus a binding, not a second channel stack
Skip (split Gateway / OS user / host) when:
- Personal high-privilege assistant, team assistant, and public ingress are different risk grades
- Users are mutually adversarial. Official line: a gateway is one trust domain.
- A deliberate rescue bot, or two configs that must not share state
- Isolation needs its own
OPENCLAW_STATE_DIR, workspace, andgateway.port— see Multiple gateways
Splitting channels, session keys, or agent ids does not split a privileged process. That is a trust boundary, not a route. The Gateway unifies channels with a normalized envelope, a session key, and an embedded loop — not one bot process per IM. Another channel is another plugin connection. Another trust boundary is another daemon. Loopback, pairing, and allowlists stay in the best-practices post.
Failure that looks like “the protocol is down”
The Gateway is a WebSocket server. A first frame that is not JSON connect closes the socket with code 1008. Usual cause: opening http://127.0.0.1:18789/ as if it were WS, or a proxy stripping the handshake.
How to see it:
openclaw gateway statusProbe target: should be ws://… (or wss://…), and Connectivity probe should be ok. A live process and a working Control UI HTTP page do not prove the RPC handshake. 1008 is the protocol gate, not a dead Telegram token; check the account only after a channel probe fails.
The other architectural misread: default DMs share main, so one person’s context leaks into another’s thread. Check session.dmScope and the keys from openclaw sessions --json before blaming the model.
Channel plugins change. Baileys and grammY are still the documented WhatsApp and Telegram implementations. Follow the current architecture page, not leftover adapter folklore. Feishu, Matrix, and Zalo are the same plugin ingress; they do not spawn a second control plane.