#Gateway returns 404 on POST /slack/events β€” Slack HTTP webhook not reachable

1 messages Β· Page 1 of 1 (latest)

zealous pewter
#

We have Slack events flowing end-to-end through our callback server β†’ HAProxy β†’ Express β†’ gateway, but the gateway at http://openclaw-gateway:18789/slack/events returns 404 on every POST.

Gateway config (confirmed via channels.status RPC):

Slack channel: running: true, configured: true
mode: "http", botToken: set, signingSecret: set, webhookPath: "/slack/events"
No errors reported (lastError: null)
What works:

Callback server receives Slack events and routes to the correct instance
HAProxy passes POST /alfred/slack/events through without auth
Express slackEventsProxy matches the path and forwards to http://openclaw-gateway:18789/slack/events
What fails:

Gateway responds 404 Not Found to every POST on /slack/events (confirmed via Alfred server logs β€” 28 consecutive 404s)
Question:
Does port 18789 serve HTTP endpoints, or is it WebSocket/RPC only? If the Slack HTTP webhook listener runs on a different port, what port should we forward to? Or is there an additional config step needed to enable the HTTP webhook handler on 18789?

gilded sandalBOT
frail plumeBOT
#

To help others find answers, you can mark your question as solved via Right click solution message -> Apps -> βœ… Mark Solution

gilded sandalBOT
molten kindleBOT
#

18789 is the right port. It serves the gateway HTTP surface and WebSocket/RPC on the same listener, so Slack HTTP webhooks should hit that same port. There is no separate Slack-only port.

I checked the current docs and source:

  • Slack docs use https://.../slack/events on the gateway host: https://docs.openclaw.ai/channels/slack
  • the Slack plugin registers /slack/events
  • the HTTP handler is wired when Slack runs in mode: "http"

So a 404 on POST /slack/events means the process answering on 18789 does not have that route active. It is not "wrong port".

Most likely causes:

  1. version/build skew, especially on an older release
  2. HAProxy/Express is forwarding to a different gateway process/config than the one channels.status is inspecting
  3. the Slack HTTP handler never mounted in that runtime

First check, what does this show?

#
openclaw --version

Then I’d run:

openclaw gateway status
openclaw status --all
openclaw logs --follow

And from the same host/container as the gateway:

curl -i -X POST http://127.0.0.1:18789/slack/events
#

If that local POST is still 404, the route is not mounted in that runtime. If local is not 404 but proxied requests are, the proxy/path rewrite layer is the problem.

There should not be any extra enable step beyond enabled: true, mode: "http", botToken, signingSecret, and webhookPath.

If you paste openclaw --version and the Slack startup log lines, I can help narrow it down quickly.

zealous pewter
#

docker exec openclaw-test-gateway-1 openclaw gateway status
Service: systemd (disabled)
File logs: /tmp/openclaw/openclaw-2026-04-22.log

Config (cli): /config/instance/config.json5
Config (service): /config/instance/config.json5

Gateway: bind=lan (0.0.0.0), port=18789 (env/config)
Probe target: ws://127.0.0.1:18789
Dashboard: http://<redacted_ip>/gateway/
Probe note: bind=lan listens on 0.0.0.0 (all interfaces); probing via 127.0.0.1.

Runtime: unknown (systemctl not available; systemd user services are required on Linux.)
RPC probe: ok

systemd user services unavailable.
systemd user services are unavailable; install/enable systemd or run the gateway under your supervisor.
If you're in a container, run the gateway in the foreground instead of openclaw gateway.

Listening: *:18789
Troubles: run openclaw status
Troubleshooting: https://docs.openclaw.ai/troubleshooting

molten kindleBOT
#

That status output helps.

A couple of key points:

  • 18789 is the correct port for Slack HTTP mode too. It is not WS-only.
  • RPC probe: ok only proves the WS/RPC side is alive, not that the Slack plugin HTTP route is mounted.
  • The /gateway/ in the dashboard URL is just the Control UI base path. It does not mean Slack should be forwarded to /gateway/slack/events. Slack should still be /slack/events on the gateway.

So, no separate port, and no extra β€œenable HTTP listener” step beyond the normal Slack HTTP config.

Given the current docs/source, a 404 on POST /slack/events means the route is not active in the process you’re hitting. At that point I’d focus on version/runtime skew, not HAProxy.

First thing I want to know:

docker exec openclaw-test-gateway-1 openclaw --version
#

Then test inside the gateway container to remove the proxy chain entirely:

docker exec openclaw-test-gateway-1 sh -lc 'curl -i -X POST http://127.0.0.1:18789/slack/events'

Interpretation:

  • 404 locally too = Slack HTTP route is not mounted in that runtime
  • not 404 locally = proxy/path rewrite problem upstream

One more useful check:

docker exec openclaw-test-gateway-1 sh -lc 'tail -n 200 /tmp/openclaw/openclaw-2026-04-22.log'
#

I’d especially look for Slack startup lines or anything around webhook/route registration.

There have been recent Slack HTTP mode fixes, so if this is an older build, upgrade is a very real possibility here.

If you paste:

  1. openclaw --version
  2. the local curl -i result
  3. the Slack-related startup log lines

…I can probably narrow this down pretty fast.

Docs:
https://docs.openclaw.ai/channels/slack
https://docs.openclaw.ai/channels/slack#troubleshooting
https://docs.openclaw.ai/gateway/troubleshooting

zealous pewter
#

docker exec openclaw-test-gateway-1 openclaw status --all
[plugins] openclaw-mem0: registered (mode: open-source, user: test, autoRecall: true, autoCapture: true, skills: false)
OpenClaw status --all

#

Overview
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Item β”‚ Value β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Version β”‚ 2026.4.15 β”‚
β”‚ OS β”‚ linux 6.12.0-124.47.1.el10_1.x86_64 (x64) β”‚
β”‚ Node β”‚ 24.14.0 β”‚

#

β”‚ Config β”‚ /config/instance/config.json5 β”‚
β”‚ Dashboard β”‚ http://<redacted_ip>:18789/gateway/ β”‚
β”‚ Tailscale β”‚ off β”‚
β”‚ Channel β”‚ stable (config) β”‚
β”‚ Update β”‚ pnpm Β· npm update 2026.4.21 β”‚
β”‚ Gateway β”‚ local Β· ws://127.0.0.1:18789 (local loopback) Β· reachable 72ms Β· auth token Β· d471be3be705 (172. β”‚
β”‚ β”‚ 16.19.4) app 2026.4.15 linux 6.12.0-124.47.1.el10_1.x86_64 β”‚
β”‚ Security β”‚ Run: openclaw security audit --deep β”‚
β”‚ Gateway self β”‚ unknown β”‚
β”‚ Gateway service β”‚ systemd not installed β”‚
β”‚ Node service β”‚ systemd not installed β”‚
β”‚ Agents β”‚ 2 total Β· 2 bootstrapping Β· 0 active Β· 5 sessions β”‚
β”‚ Secrets β”‚ none β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

#

Channels
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Channel β”‚ Enabled β”‚ State β”‚ Detail β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Slack β”‚ ON β”‚ OK β”‚ credentials ok (bot config, signing config) (bot <redacted> Β· len 59, signing β”‚
β”‚ β”‚ β”‚ β”‚ <redacted> Β· len 32) Β· accounts 1/1 β”‚
β”‚ Telegram β”‚ ON β”‚ OK β”‚ token config (<redacted> Β· len 46) Β· accounts 1/1 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Slack accounts
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Account β”‚ Status β”‚ Notes β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ default β”‚ OK β”‚ bot:config Β· app:config Β· signing:config β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Telegram accounts
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Account β”‚ Status β”‚ Notes β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ main β”‚ OK β”‚

molten kindleBOT
#

That status --all paste did not get to the useful part yet, it only shows the mem0 plugin banner.

The two most valuable checks now are still these:

docker exec openclaw-test-gateway-1 openclaw --version
docker exec openclaw-test-gateway-1 sh -lc 'curl -i -X POST http://127.0.0.1:18789/slack/events'

If that curl returns 404 from inside the container, then we can stop blaming HAProxy/Express. It means the Slack HTTP route is not mounted in that gateway runtime.

Then I’d grab just the Slack-related log lines:

docker exec openclaw-test-gateway-1 sh -lc 'grep -iE "slack|webhook|route" /tmp/openclaw/openclaw-2026-04-22.log | tail -n 100'
#

So, the minimum useful pasteback is:

  1. openclaw --version
  2. local curl -i -X POST http://127.0.0.1:18789/slack/events
  3. the Slack/webhook log lines

That should tell us pretty quickly whether this is:

  • an old version,
  • a route-mount bug,
  • or a runtime/config mismatch.
#

Nice, that helps.

2026.4.15 is recent enough that this is probably not the older Slack HTTP-mode startup issue I was worried about. Node 24 is fine too.

So the next check is the important one:

docker exec openclaw-test-gateway-1 sh -lc 'curl -i -X POST http://127.0.0.1:18789/slack/events'

If that returns 404 inside the container, then we know for sure:

  • port 18789 is reachable
  • HAProxy/Express are not the problem
  • the Slack webhook route is not mounted in that gateway runtime

Then please grab the Slack-related log lines:

#
docker exec openclaw-test-gateway-1 sh -lc 'grep -iE "slack|webhook|route" /tmp/openclaw/openclaw-2026-04-22.log | tail -n 100'

At this point, the remaining likely buckets are:

  1. runtime/config mismatch
  2. Slack HTTP handler not mounting despite status saying running
  3. a route registration bug/regression

If you paste the curl -i result next, we can narrow it down a lot.

zealous pewter
#

docker exec openclaw-test-gateway-1 wget -SO- --post-data='{"type":"url_verification","challenge":"test"}' --header='Content-Type: application/json' http://gateway:18789/slack/events
--2026-04-22 08:51:54-- http://gateway:18789/slack/events
Resolving gateway (gateway)... <redacted_ip>
Connecting to gateway (gateway)|<redacted_ip>|:18789... connected.
HTTP request sent, awaiting response...
HTTP/1.1 404 Not Found
X-Content-Type-Options: nosniff
Referrer-Policy: no-referrer
Permissions-Policy: camera=(), microphone=(), geolocation=()
Content-Type: text/plain; charset=utf-8
Date: Wed, 22 Apr 2026 08:51:54 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Content-Length: 9
2026-04-22 08:51:54 ERROR 404: Not Found.

molten kindleBOT
#

This narrows it down a lot.

What this already confirms:

  • 18789 definitely serves HTTP, not just WebSocket/RPC
  • /gateway/ is only the dashboard UI base path
  • Slack should still be forwarded to /slack/events, not /gateway/slack/events
  • the systemd not installed / Gateway self unknown bits are normal container noise here, not the root cause

So there is still no separate Slack port and no extra config step I’d expect.

Two things now stand out:

  1. You are on 2026.4.15
  2. status --all says update available: 2026.4.21

Given how much this smells like route mounting/runtime behavior, I would update first if that’s easy in your container setup. You are a few stable releases behind current.

#

But the decisive check is still this:

docker exec openclaw-test-gateway-1 sh -lc 'curl -i -X POST http://127.0.0.1:18789/slack/events'

That one command tells us almost everything:

  • 404 locally = gateway runtime problem / route not mounted
  • not 404 locally = proxy chain problem

If you can, I’d do these next, in this order:

docker exec openclaw-test-gateway-1 sh -lc 'curl -i -X POST http://127.0.0.1:18789/slack/events'
docker exec openclaw-test-gateway-1 sh -lc 'grep -iE "slack|webhook|route" /tmp/openclaw/openclaw-2026-04-22.log | tail -n 100'
#

If it still 404s locally after updating to 2026.4.21, I’d start treating this as a likely bug/regression rather than config.

If you paste the local curl -i result, we can stop guessing and pin it down.

#

Yep, this makes the picture pretty clear:

  • your Slack credentials/config look fine
  • 18789 is still the correct HTTP port
  • the correct target is still http://openclaw-gateway:18789/slack/events
  • there is no separate Slack webhook port
  • there is no extra config switch that should be needed beyond the Slack HTTP config you already have

One important nuance: this status output proves credentials, not that the HTTP route is actually mounted.

So if POST /slack/events is really returning 404 on that gateway, with this config, that is not expected behavior. At that point I’d call it one of two things:

  1. a runtime/route-mount issue in that container
  2. a bug/regression

Since you are on 2026.4.15 and status --all showed 2026.4.21 available, my recommendation is:

#
  1. update to 2026.4.21
  2. test locally inside the container with:
    curl -i -X POST http://127.0.0.1:18789/slack/events
    
  3. if that still returns 404, file a bug here with the outputs you already collected:
    https://github.com/openclaw/openclaw/issues

If you want the short direct answer to your original question:

Forward to port 18789. It serves HTTP too. /slack/events is the right path. A 404 there is abnormal.

If you paste the local curl -i result after updating, I can help you word the bug report cleanly.

#

Perfect, that’s the decisive signal.

That local wget proves the problem is inside the gateway/runtime, not in Slack, HAProxy, or your Express proxy.

So the verified answer is:

  • port 18789 does serve HTTP
  • /slack/events is the correct path
  • there is no separate Slack webhook port
  • a 404 from that local POST is abnormal

In other words, the Slack HTTP route is not mounted in the running gateway, even though the channel status says Slack is configured/OK.

At this point I would stop debugging the proxy chain and do this:

  1. Update to 2026.4.21
  2. Re-test locally, ideally with 127.0.0.1 just to remove any service-name ambiguity:
#
   docker exec openclaw-test-gateway-1 wget -SO- \
     --post-data='{"type":"url_verification","challenge":"test"}' \
     --header='Content-Type: application/json' \
     http://127.0.0.1:18789/slack/events
  1. If it still returns 404, file a bug:
    https://github.com/openclaw/openclaw/issues

What I’d include in the issue:

  • OpenClaw version: 2026.4.15 (and whether 2026.4.21 reproduces)
  • containerized deployment
  • Slack config summary: mode:"http", webhookPath:"/slack/events", bot+signing secret set
  • channels status showing Slack OK
  • the exact local wget 404 result from inside the container
  • note that dashboard is on /gateway/, but Slack webhook was tested on /slack/events
#

Short version for the issue title:

Slack HTTP mode configured and healthy in channels status, but local POST /slack/events on port 18789 returns 404

If you want, paste the startup log lines next and I can help tighten the bug report so it’s maintainer-friendly.

zealous pewter
#

nevermind, let's wipe the slate clean and start with something new

I'm working on a dashboard (the web UI that sits in front of OpenClaw instances). We're looking at adding a web-based chat panel so users can talk to their main agent directly from the browser, instead of only through Telegram/Slack.

We already use chat.send via the gateway WebSocket to fire-and-forget messages. But for a proper chat UI we need a few more things, and I want to make sure I'm building against capabilities that exist (or are planned) rather than hacking around gaps.

  1. Session history / message retrieval

Is there an RPC (or planned one) to fetch past messages for a given session key? Something like chat.history that takes a sessionKey and returns the message log. Right now we construct session keys like agent:main:telegram:dm:<accountId> β€” would a web-originated session use the same convention, or would it need a different channel prefix (e.g. agent:main:web:dm:<userId>)?

  1. Streaming responses

When the agent is generating a reply, does the gateway emit incremental events (like chat.token or chat.chunk) on the WebSocket, or does it only deliver the final completed message? For the chat UI to feel responsive we'd want to stream tokens as they arrive rather than wait for the full response.

#
  1. Web as a first-class channel

Today chat.send requires a paired channel (Telegram/Slack) and a corresponding account_id. For a web chat, there's no external pairing β€” the user is already authenticated via Supabase. Is there a way to open a session that's not tied to a Telegram/Slack account? Or should we synthesize a virtual pairing for web sessions?

  1. Session lifecycle

Does the gateway track session state (open/closed/expired)? If a user closes the browser tab and comes back later, can we resume the same session, or do we need to start fresh? Is there a TTL on sessions?

  1. Delivery vs. non-delivery mode

We use deliver: true in chat.send so the reply goes back through the channel (Telegram bot sends the response). For web chat, we'd want deliver: false and instead consume the reply directly from the WebSocket event stream. Is that the right pattern, or is there a dedicated "web reply" delivery mechanism?

  1. Multi-turn context

Does the gateway maintain conversation context per session key automatically, or does the caller need to pass message history with each chat.send? Basically β€” if I send 5 messages on the same session key, does the agent see the full conversation, or only the latest message?

molten kindleBOT
#

Yep, this exists today, you do not need to invent around it.

  1. History / retrieval

Use chat.history.

Request shape today:

  • { sessionKey, limit?, maxChars? }

Response includes:

  • { sessionKey, sessionId, messages, thinkingLevel, fastMode, verboseLevel }

Important limits:

  • default limit is 200
  • max limit is 1000
  • no cursor/pagination RPC on chat.history today
  • it is UI-oriented, so messages can be normalized/truncated, and oversized ones can become [chat.history omitted: message too large]
#

Session key advice:

  • If the browser should talk to the agent’s shared main thread, use main / agent:main:main
  • If you want a browser-owned direct session, use the internal webchat prefix, not web, for example agent:main:webchat:dm:<userId>
  • If you let sessions.create mint a fresh dashboard session with no requested key, it generates agent:<agentId>:dashboard:<uuid>

So, for your example, I would not use agent:main:web:dm:<userId>. If you want an internal browser/user DM shape, use webchat.

  1. Streaming

There is no chat.token / chat.chunk event family.

Streaming happens through:

  • event: "chat"

And you should consume payload.state:

  • state: "delta" = streaming update
  • state: "final" = completed reply
#
  • state: "error" = failed reply
  • state: "aborted" = stopped run

Important nuance: delta carries the full assistant text so far, not just the newest token. So your UI should replace/update the in-progress assistant bubble, not append raw chunks blindly.

There is also:

  • event: "chat.side_result"

That is for side-channel results like /btw, not normal assistant token streaming.

  1. Send lifecycle

chat.send is still the right entrypoint.

Ack behavior:

  • first send: { runId, status: "started" }
  • same idempotencyKey while running: { status: "in_flight" }
#
  • same idempotencyKey after completion: { status: "ok" }

Practical dashboard loop:

  • on open: call chat.history
  • on submit: call chat.send
  • while running: listen for event: "chat" with matching runId / sessionKey
  • render delta, then finalize on final

One extra nice bit, webchat clients are treated as internal, so they do not inherit external Slack/Telegram delivery routes by default. That’s the right behavior for a browser chat panel.

Relevant refs:

  • <https://docs.openclaw.ai/web/webchat>
  • <https://docs.openclaw.ai/web/control-ui>
  • <https://docs.openclaw.ai/channels/channel-routing>

If you want, I can turn this into a concrete dashboard integration contract, request/response examples plus the exact client-side state machine.

#

Yep, for 3 to 6:

3. Web as a first-class channel

You do not need a fake Telegram/Slack pairing.

chat.send itself does not require accountId. For browser chat, the important thing is the sessionKey, not an external channel binding.

What exists today:

  • shared main session: main / agent:<agentId>:main
  • internal webchat-style session: agent:<agentId>:webchat:dm:<userId>
  • auto-created dashboard session: agent:<agentId>:dashboard:<uuid> via sessions.create

So I would not synthesize a virtual pairing. I’d treat browser chat as an internal UI surface.

My recommendation:

  • if this is β€œtalk to my main agent from the browser”, use main
#
  • if you need per-user isolation, use a stable internal key like agent:main:webchat:dm:<supabaseUserId>
  • if you need ephemeral tabs/workspaces, let sessions.create mint dashboard: keys

Also, there is no web outbound channel today. The internal names are webchat and dashboard.

4. Session lifecycle

The gateway owns sessions and transcripts.

So:

  • closing a browser tab does not close the session
  • if the user comes back later, you can resume by reusing the same sessionKey
  • repopulate the UI with chat.history

Expiry is policy-driven, not socket-driven:

  • default daily reset at 4:00 AM gateway-local time
  • optional idle reset via session.reset.idleMinutes
#
  • manual reset via /new or /reset

There is not really an β€œopen/closed tab session state” I’d build against in the gateway. Think of it more as:

  • sessionKey = stable conversation identity
  • sessionId = current transcript backing that key

If a reset boundary is hit, the same sessionKey can roll to a new sessionId and start fresh.

5. Delivery vs non-delivery

Yes, for web chat the right pattern is:

  • deliver: false (or just omit deliver)
  • consume replies from the WebSocket chat event stream

There is no separate dedicated β€œweb reply delivery” mechanism. The WS stream is the delivery path for webchat/dashboard.

#

When deliver is not enabled, chat.send stays on the internal webchat surface instead of routing back out through Telegram/Slack.

chat.inject is the extra helper if you want to append an assistant note to the transcript/UI without starting an agent run.

6. Multi-turn context

Yes, context is maintained automatically per sessionKey.

You do not need to pass full message history on each chat.send.

If you send 5 messages on the same sessionKey, the agent sees that conversation via the gateway-owned session transcript and session state.

One nuance:

  • long sessions can be affected by compaction/pruning/reset policy
  • so the model may not always get the raw full transcript verbatim forever
  • but the conversation context is still managed by the gateway, not by the caller
zealous pewter
#
  1. chat.history message shape

What's the exact structure of each message in the messages array returned by chat.history? Specifically:

Is the text in .content, .text, or something else?
Is .content always a string, or can it be an array of content blocks (like [{type: "text", text: "..."}])?
Is the role in .role or .type?
What other fields does each message have? (e.g. timestamp, id, runId, thinking)
A sample JSON of one user message and one assistant message from the response would be ideal.

  1. event: "chat" payload shape for delta / final

When the gateway emits event: "chat" with state: "delta", where is the accumulated text? Is it in payload.content, payload.text, payload.message, or nested somewhere else?

Same question for state: "final" β€” is the complete response in the same field as delta, or a different one?

And when I send with sessionKey: "main", do the events come back with sessionKey: "main" or the resolved form like sessionKey: "agent:main:main"?

molten kindleBOT
#

Yep. The slightly annoying truth is:

  • event: "chat" is fairly well-shaped
  • chat.history.messages[] is looser, because it is basically sanitized transcript data, not a tightly versioned mini-schema

1. chat.history message shape

What is stable enough to build against

For each entry in messages:

  • role is in .role
  • text is usually in .content
  • .content can be:
    • a string
    • or an array of blocks, commonly [{ type: "text", text: "..." }]
  • some entries can also have a top-level .text
#
  • .type is not the message role, it is typically a content-block field

Practical rule for text extraction

For a robust client, do:

  1. if message.text is a string, use it
  2. else if message.content is a string, use it
  3. else if message.content is an array, read the text blocks from it

That matches how OpenClaw’s own UI code stays defensive.

Common fields you may see

Often:

  • role
  • content
#
  • timestamp

Sometimes:

  • text
  • usage (assistant only)
  • cost (assistant only)
  • phase
  • provider
  • model
  • tool/media-related fields
  • canvas/media blocks inside content

Important nuance:

  • chat.history preserves transcript shape pretty loosely
  • it sanitizes/truncates, strips display-only tags, and removes some heavy/internal fields
  • there is not a strict typed wire contract for each row beyond β€œtranscript-like message objects”
#

Representative samples

User message:

{
  "role": "user",
  "content": [
    { "type": "text", "text": "hello" }
  ],
  "timestamp": 1713930000000
}

Assistant message:

{
#
  "role": "assistant",
  "content": [
    { "type": "text", "text": "hello from Claude" }
  ],
  "timestamp": 1713930001200,
  "usage": { "input": 12, "output": 5, "totalTokens": 17 },
  "cost": { "total": 0.0123 }
}

Also valid in history:

{
  "role": "assistant",
  "text": "real text field reply",
#
  "content": "fallback content",
  "timestamp": 1713930001300
}

So if you want a resilient dashboard client, normalize rather than assuming one exact content shape.


2. event: "chat" payload shape for delta / final

This one is much cleaner.

Top-level payload shape:

#
{
  "runId": "idem-123",
  "sessionKey": "agent:main:main",
  "seq": 1,
  "state": "delta",
  "message": {
    "role": "assistant",
    "content": [
      { "type": "text", "text": "Hello" }
    ],
    "timestamp": 1713930002000
  }
}

Where is the text?

#

For current gateway emission, the text is in:

  • payload.message.content[0].text

Not payload.text, not top-level payload.content.

delta vs final

They use the same payload.message field.

delta example:

{
  "runId": "idem-123",
  "sessionKey": "agent:main:main",
  "seq": 1,
#
  "state": "delta",
  "message": {
    "role": "assistant",
    "content": [
      { "type": "text", "text": "Hello wor" }
    ],
    "timestamp": 1713930002000
  }
}

final example:

{
  "runId": "idem-123",
#
  "sessionKey": "agent:main:main",
  "seq": 2,
  "state": "final",
  "message": {
    "role": "assistant",
    "content": [
      { "type": "text", "text": "Hello world" }
    ],
    "timestamp": 1713930002600
  },
  "stopReason": "stop"
}

Important nuance:

  • delta contains the accumulated text so far, not just the newest token/chunk
#
  • so your UI should replace/update the in-progress assistant bubble

Also:

  • final can have no message for silent/no-output cases
  • error uses errorMessage instead of a normal assistant message

3. sessionKey: "main" vs canonical session key in events

For emitted chat events, expect the canonical form:

  • send with: "main"
  • event comes back as: "agent:main:main"

That is what current gateway behavior/tests show.

#

One subtle distinction:

  • chat.history top-level response echoes your requested sessionKey
  • but chat events from runs are generally emitted on the canonical session key

So for the live event stream, I would build against canonical keys.


Recommendation for the dashboard client

I’d normalize like this:

  • for history rows:
    • accept text, string content, or block-array content
  • for live chat events:
    • expect payload.message.content[]
    • but tolerate string-shaped content just in case