Contents

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/*.md

Those names are from current docs. Nothing named “Message Router” is a separate process. The FAQ path is literal:

Telegram → Gateway → Agent → node.* → Node → Gateway → Telegram

Nodes 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/models and 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:

  1. Exactly one Gateway opens a Baileys / WhatsApp session on a given host.
  2. Inbound WS frames are schema-validated (TypeBox). The first client frame must be connect; anything else is a hard close.
  3. Events are not replayed. On a sequence gap, the client refreshes.
  4. 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 --json

gateway 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:

sequenceDiagram participant Client participant Gateway Client->>Gateway: req:connect Gateway-->>Client: res (ok) Note right of Gateway: or res error + close Note left of Client: payload=hello-ok<br>snapshot: presence + health Gateway-->>Client: event:presence Gateway-->>Client: event:tick Client->>Gateway: req:agent Gateway-->>Client: res:agent<br>ack {runId, status:"accepted"} Gateway-->>Client: event:agent<br>(streaming) Gateway-->>Client: res:agent<br>final {runId, status, summary}

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.sqlite

Defaults:

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 start
  • memory/YYYY-MM-DD.md: daily notes, recalled by search, not stuffed into every prompt
  • USER.md / SOUL.md / AGENTS.md: persona and preferences; also workspace files, not Gateway SQLite

The cut:

  1. Session = this conversation’s transcript and routing key. Bounded by the model window; compaction shrinks it. Gateway-owned.
  2. Memory = notes that should survive a /new. The model remembers what was written to disk. Workspace-owned.
  3. 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 to tick / 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.entries plus 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, and gateway.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 status

Probe 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.