#Webhooks

73 messages ¡ Page 1 of 1 (latest)

sullen plinth
#

I'm testing the hooks, so the /hooks/wake will just run the heartbeat? so what's the "text" field for?

lofty wolfBOT
zinc flintBOT
#

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

lofty wolfBOT
worldly jettyBOT
#

Yep, basically, but not as a bare heartbeat.

POST /hooks/wake does 2 things:

  1. enqueues a system event on the main session
  2. optionally wakes heartbeat
    • mode: "now" = run it immediately
    • mode: "next-heartbeat" = wait for the next scheduled heartbeat

The text field is the reason/context for the wake. It gets injected into the prompt as a System: line, so the agent knows why it was woken.

So this:

{ "text": "New email received", "mode": "now" }
sullen plinth
#

with /hooks/agent I can't seem to send direct dm through discord. Any tips?

worldly jettyBOT
#

A few gotchas there.

For Discord DMs with /hooks/agent, I’d try an explicit target, not channel: "last":

{
  "message": "test dm",
  "name": "Webhook test",
  "channel": "discord",
  "to": "user:417043254136012801"
}

Main things to check:

  • Use user:<id>, not a bare numeric ID.
    Discord DM delivery expects user:<id> or a mention like <@id>. Bare IDs are ambiguous and usually rejected.
#
  • Don’t rely on last if you want a DM.
    last will try to reuse the previous route, which is often the current thread/channel, not a DM.
  • Make sure Discord DMs are actually allowed:
    • channels.discord.dm.enabled is not false
    • channels.discord.dmPolicy is not "disabled"
    • if you’re using pairing mode, that user may need to be paired/allowed first
    • the user must allow DMs from server members / share a server with the bot

So the most likely fix is: set channel: "discord" and to: "user:<id>" explicitly.

Docs:

If that still fails, send:

  1. openclaw --version
  2. the exact JSON you’re POSTing
#
  1. the error/log line

Glad that helped! 🎉 If this fixed it, please right-click the solution message → Apps → ✅ Mark Solution.

sullen plinth
#

can you send me full list of json properties that I can send with the hook? the "channel" and "to" aren't mentioned in the docs

worldly jettyBOT
#

Yep. From current source, these are the JSON fields actually read.

POST /hooks/wake

{
  "text": "New email received",
  "mode": "now"
}

Fields:

  • text required
  • mode optional, "now" or "next-heartbeat" (default: "now")

#

POST /hooks/agent

{
  "message": "Summarize inbox",
  "name": "Email",
  "agentId": "main",
  "idempotencyKey": "abc123",
  "wakeMode": "now",
  "sessionKey": "hook:email",
  "deliver": true,
  "channel": "discord",
  "to": "user:417043254136012801",
  "model": "openai/gpt-5.4-mini",
  "thinking": "medium",
  "timeoutSeconds": 60
#
}

Fields:

  • message required
  • name optional, default: "Hook"
  • agentId optional
  • idempotencyKey optional
  • wakeMode optional, "now" or "next-heartbeat" (default: "now")
  • sessionKey optional, but only works if hooks.allowRequestSessionKey=true
  • deliver optional, default: true
  • channel optional, default: "last"
    • valid values are "last" plus installed channel ids like "discord", "telegram", etc.
  • to optional, delivery target
    • for Discord DM, use user:<id> or <@id>
#
  • model optional
  • thinking optional
  • timeoutSeconds optional, positive integer

A couple of gotchas:

  • channel + to are only meaningful when you want explicit delivery.
  • If you omit channel, it defaults to "last".
  • If you send a sessionKey and haven’t enabled hooks.allowRequestSessionKey, it gets rejected.
  • Extra JSON keys are ignored unless you’re using a mapped hook.

Docs on current main do list channel and to under /hooks/agent, so if you’re not seeing that, I’m a little suspicious you’re on older docs or an older release. What does openclaw --version show?

Refs:

sullen plinth
#

so sessionKey is just for filtering, not for actual message delivery?

worldly jettyBOT
#

Not just filtering.

sessionKey is mainly for which session context the isolated run uses. That means:

  1. it decides which transcript/history the hook run reads/writes
  2. it can indirectly affect delivery when you use channel: "last" (or omit channel/to), because OpenClaw can reuse that session’s stored lastChannel, lastTo, and thread context

So:

  • sessionKey = which session am I running in?
  • channel + to = where should the output be delivered?

If you set explicit delivery like:

{
#
  "sessionKey": "hook:email",
  "channel": "discord",
  "to": "user:417043254136012801"
}

then channel/to win for delivery.

If you do this instead:

{
  "sessionKey": "hook:email",
  "channel": "last"
}
sullen plinth
#

so event hooks/agent has wakeMode? what it's for? I thought you said hooks/agent will be run as isolated agent? will it be a heartbeat?

worldly jettyBOT
#

Yeah, the name is a bit confusing 😅

/hooks/agent is not a heartbeat run. It does an isolated agent turn.

wakeMode there is only about what happens after that isolated run, if OpenClaw needs to surface the result back to the main session:

  • wakeMode: "now"
    enqueue a summary/error into the main session and wake heartbeat immediately
  • wakeMode: "next-heartbeat"
    enqueue the summary/error into the main session, but wait for the next normal heartbeat

So the mental model is:

  • /hooks/wake = “poke the main session / heartbeat”
  • /hooks/agent = “run a separate isolated agent job”
  • wakeMode on /hooks/agent = “if that isolated job needs to notify the main session, should that notification wake heartbeat now or later?”
#

One more nuance: if the isolated /hooks/agent run is already delivered externally (for example via explicit channel/to), then there may be nothing to wake at all.

Docs:

If you want, I can sketch the exact flow for:

  1. /hooks/wake
  2. /hooks/agent with delivery
  3. /hooks/agent without delivery
sullen plinth
#

yes please

worldly jettyBOT
#

Sure, here’s the exact mental model:

1) /hooks/wake

Purpose: poke the main session

Flow:

  1. your POST sends { text, mode }
  2. OpenClaw enqueues text as a system event on the main session
  3. if mode: "now", it triggers heartbeat immediately
  4. if mode: "next-heartbeat", it waits for the next scheduled heartbeat

So this is basically:

“main session, wake up, here’s why”

#

2) /hooks/agent with delivery

Purpose: run a separate isolated agent turn and send the result out directly

Example:

{
  "message": "Summarize this alert",
  "channel": "discord",
  "to": "user:417043254136012801"
}

Flow:

#
  1. OpenClaw starts an isolated agent run
  2. that run generates a reply
  3. reply is delivered directly to the target (channel + to)
  4. since it was already delivered, there may be nothing to wake heartbeat for

So this is basically:

“run this separately, and send the result there”


3) /hooks/agent without delivery

Example:

#
{
  "message": "Check this webhook payload",
  "deliver": false,
  "wakeMode": "now"
}

Flow:

  1. OpenClaw starts an isolated agent run
  2. it finishes, but does not deliver externally
  3. OpenClaw writes a short summary/error back to the main session as a system event
  4. wakeMode decides whether the main session heartbeat runs now or later

So this is basically:

#

“run this separately, then tell the main session what happened”


Short version

  • /hooks/wake = wake main session
  • /hooks/agent = run isolated agent
  • wakeMode on /hooks/agent = controls follow-up notification to main session, not whether the isolated run itself becomes a heartbeat

Docs: https://docs.openclaw.ai/automation/cron-jobs#webhooks

Glad that helped! 🎉 If this fixed it, please right-click the solution message → Apps → ✅ Mark Solution.

sullen plinth
#

so if I want to call an agent in my discord dm session (so it will get all current context of the dm session), I'll use

/hooks/agent
sessionKey: <dm session key>
agentId: "main"
channel: "discord"
to: <dm user>

correct?

worldly jettyBOT
#

Not quite.

If your goal is:

run in the existing Discord DM session and get all current DM context/history

then /hooks/agent is the wrong one.

Why: /hooks/agent always creates an isolated agent turn. In current source it sets sessionTarget: "isolated", which means it starts a fresh session for that run. So passing the DM sessionKey does not make it continue the live DM transcript the way a normal DM message would.

So:

This is correct for:

an isolated run, delivered to your DM

#
{
  "agentId": "main",
  "channel": "discord",
  "to": "user:<your_discord_id>"
}

This is not correct for:

“use my current DM conversation history/context”

Even if you add:

"sessionKey": "<dm session key>"
#

that still does not turn /hooks/agent into “continue this chat normally”.

Short version

  • want existing DM context → use the normal session/chat path, not /hooks/agent
  • want isolated job + send result to DM → use /hooks/agent

I’d also avoid pointing /hooks/agent at a live DM session key unless you really know why you need it.

Docs: https://docs.openclaw.ai/automation/cron-jobs#webhooks

If you want, I can help map your exact goal to the right endpoint:

  1. “continue my DM conversation”
  2. “run isolated, but send result to DM”
  3. “poke the main session from a webhook”
sullen plinth
#

so there is no way to externally trigger an agent round in a session, with continuity?

worldly jettyBOT
#

Yes, but not via /hooks/agent.

The clean split is:

  • /hooks/* = simple external HTTP triggers
    • /hooks/wake → poke main session
    • /hooks/agent → run isolated turn
  • Gateway session APIs = continue an existing session with continuity

So if you want:

externally trigger a run in an existing session, with its history/context

use the normal session/chat surface, for example:

  • chat.send with the target sessionKey
  • or sessions.send into that existing session
#
  • or the lower-level agent RPC with sessionKey

That preserves continuity.

So the answer is:

  • through hooks: no, not today
  • through Gateway RPC/session APIs: yes

If you specifically want a webhook-shaped HTTP endpoint that continues a live session, I don’t think there’s a built-in /hooks/... route for that right now. You’d usually put a tiny proxy in front of chat.send / sessions.send.

Docs:

If you want, I can sketch the exact request shape you’d use for “continue this DM session via chat.send”.

sullen plinth
#

from your 3 proposed solutions using normal chat surface, can you tell me the difference?

is there any option the will be displayed as my chat message in discord, and is there one that won't?

worldly jettyBOT
#

Yep. Short version:

| Option                | Keeps session continuity? | Sends anything to Discord? | Can appear as your Discord message? |
| --------------------- | ------------------------- | -------------------------- | ----------------------------------- |
| sessions.send         | yes                       | no                         | no                                  |
| chat.send             | yes                       | optional                   | no                                  |
| agent with sessionKey | yes                       | optional                   | no                                  |

What each one is for

**sessions.send**

• easiest “send a message into this existing session”
• basically a convenience wrapper around chat.send
• internal/session only
• good when you want continuity but no external Discord post

#

**chat.send**

• the normal chat/session API
• keeps continuity for that sessionKey
• can be internal only by default
• if you set deliver: true, the assistant reply can go out to Discord using the session’s route or an explicit originating route

**agent**

• lower-level run API
• also keeps continuity if you pass sessionKey
• gives you more knobs like model/provider/channel/to/replyTo/bestEffortDeliver
• best when you’re building a custom integration/client

Important Discord answer

If you mean:

#

“can OpenClaw make it look like I wrote the Discord message?”

Then no, not through these APIs. OpenClaw won’t spoof your user message on Discord.

What can happen is:

• internal only: nothing shows in Discord
• external delivery: the bot/assistant reply shows in Discord

But the injected user turn is just in the session transcript, not a Discord message authored by you.

So if you want:

• continuity, no Discord output → sessions.send (or chat.send without deliver)
• continuity, plus bot reply in Discord → chat.send with deliver: true or agent with delivery
• a real message from your own Discord account → you have to actually send it yourself

sullen plinth
#

yes please

worldly jettyBOT
#

Yep. Here are the 3 minimal examples.

These are Gateway RPC method params, not /hooks/* payloads.

Also, for all 3:

  • they keep continuity
  • none of them will show up as your Discord message
  • only #2 and #3 can result in a bot reply showing in Discord

1) Silent continuation, no Discord output

Use sessions.send

#
{
  "key": "agent:main:discord:direct:user:417043254136012801",
  "message": "Summarize the last few messages into 3 bullets."
}

What happens:

  • continues that session
  • runs the agent
  • stays internal/session-only
  • nothing is posted to Discord

2) Continue session, let the bot reply into the DM

#

Use chat.send

{
  "sessionKey": "agent:main:discord:direct:user:417043254136012801",
  "message": "Reply to the latest question in one paragraph.",
  "deliver": true,
  "idempotencyKey": "demo-chat-send-1"
}

What happens:

  • continues that session
  • if the session already has a valid Discord DM route, the assistant reply gets delivered there
  • still not shown as your message

#

3) Continue session, but force a strict explicit Discord DM route

Use agent

{
  "agentId": "main",
  "sessionKey": "agent:main:discord:direct:user:417043254136012801",
  "message": "Answer the latest DM using the existing context.",
  "deliver": true,
  "channel": "discord",
  "to": "user:417043254136012801",
  "bestEffortDeliver": false,
  "idempotencyKey": "demo-agent-1"
}
#

If you want, I can also write the exact WS envelope for one of these, like:

{ "id": 1, "method": "chat.send", "params": { ... } }
sullen plinth
#

how can a third party script call "chat.send"?

worldly jettyBOT
#

Through the Gateway WebSocket protocol, not via a simple /hooks/*-style HTTP POST.

In practice

A third-party script has to:

  1. connect to the Gateway WS
  2. handle the connect.challenge
  3. send a connect request with:
    • gateway auth (token or password)
    • device identity + signed nonce
    • role/scopes, usually operator.read + operator.write
  4. then send a normal RPC request for chat.send

Minimal shape

1) Gateway sends challenge

#
{
  "type": "event",
  "event": "connect.challenge",
  "payload": { "nonce": "..." }
}

2) Script sends connect

{
  "type": "req",
  "id": "1",
  "method": "connect",
  "params": {
    "minProtocol": 3,
#
    "maxProtocol": 3,
    "client": {
      "id": "my-script",
      "version": "0.1.0",
      "platform": "linux",
      "mode": "operator"
    },
    "role": "operator",
    "scopes": ["operator.read", "operator.write"],
    "auth": { "token": "YOUR_GATEWAY_TOKEN" },
    "device": {
      "id": "device_fingerprint",
      "publicKey": "...",
      "signature": "...",
      "signedAt": 1737264000000,
#
      "nonce": "..."
    }
  }
}

3) Then call chat.send

{
  "type": "req",
  "id": "2",
  "method": "chat.send",
  "params": {
    "sessionKey": "agent:main:discord:direct:user:417043254136012801",
    "message": "Reply using the existing DM context.",
#
    "deliver": true,
    "idempotencyKey": "demo-chat-send-1"
  }
}

Important gotcha

This is a bit heavier than a normal webhook because raw WS clients must implement device auth/pairing, not just paste the gateway token.

So the practical answer is:

  • possible: yes
  • easy from a random script: not really
  • easiest pattern: write a tiny local bridge that speaks Gateway WS once, then expose your own simple HTTP endpoint to your external script
sullen plinth
#

yes

worldly jettyBOT
#

Cool. The practical bridge I’d build is this:

Easiest bridge: local HTTP -> openclaw agent

This avoids implementing raw Gateway WS auth/device pairing yourself.

Tiny Node example

import http from "node:http";
import { execFile } from "node:child_process";
import { promisify } from "node:util";

const execFileAsync = promisify(execFile);

const server = http.createServer(async (req, res) => {
#
  if (req.method !== "POST" || req.url !== "/continue-dm") {
    res.writeHead(404);
    return res.end("not found");
  }

  let body = "";
  req.on("data", (chunk) => (body += chunk));
  req.on("end", async () => {
    try {
      const { userId, message, deliver = true } = JSON.parse(body);

      if (!userId || !message) {
        res.writeHead(400, { "content-type": "application/json" });
        return res.end(JSON.stringify({ error: "userId and message are required" }));
      }
#

      const args = [
        "agent",
        "--agent", "main",
        "--channel", "discord",
        "--to", `user:${userId}`,
        "--message", message,
        "--json"
      ];

      if (deliver) args.push("--deliver");

      const { stdout, stderr } = await execFileAsync("openclaw", args);

      res.writeHead(200, { "content-type": "application/json" });
#
      res.end(JSON.stringify({
        ok: true,
        stdout: stdout ? JSON.parse(stdout) : null,
        stderr: stderr || null
      }));
    } catch (err) {
      res.writeHead(500, { "content-type": "application/json" });
      res.end(JSON.stringify({ ok: false, error: String(err) }));
    }
  });
});

server.listen(8787, "127.0.0.1", () => {
  console.log("bridge listening on http://127.0.0.1:8787");
});
#

Call it like this

curl -X POST http://127.0.0.1:8787/continue-dm \
  -H 'Content-Type: application/json' \
  -d '{
    "userId": "417043254136012801",
    "message": "Reply using the current DM context",
    "deliver": true
  }'

What this gives you

  • same DM route, so you get continuity
  • if deliver: true, the bot reply goes back to Discord
  • if deliver: false, it stays internal/silent
#

When to use raw chat.send instead

Only if you specifically need:

  • direct sessionKey control
  • a persistent WS client
  • richer session APIs than the CLI wrapper gives you

Otherwise, this bridge is way simpler.

Docs:

If you want, I can also give you:

  1. a Python version, or
  2. a raw WebSocket chat.send example instead
sullen plinth
#

but with cli will the agent turn be isolated?

worldly jettyBOT
#

Nope, not isolated in the /hooks/agent sense.

openclaw agent is a normal agent turn. It resolves a real session via:

  • --to
  • --session-id
  • or --agent

and then runs with continuity for that session.

So if you do something like:

openclaw agent \
  --agent main \
  --channel discord \
#
  --to user:417043254136012801 \
  --message "Reply using the current DM context" \
  --deliver

that is meant to behave like:

“send a normal turn into that routed session”

not:

“spin up an isolated throwaway run”

That’s exactly the difference vs /hooks/agent, which is always isolated.

Only caveat: continuity depends on you targeting the same session route consistently. If you change the selector, you’ll hit a different session.

sullen plinth
#

I'm sending via cli like this

openclaw agent
--agent main
--session-id "discord:direct:417043254136012801"
--reply-channel discord
--reply-to "user:417043254136012801"
--reply-account main
--deliver
--message "[message]"

but it keeps going to agent:main:main session.

worldly jettyBOT
#

Ah, yep, the problem is this part:

--session-id "discord:direct:417043254136012801"

That is not a session id. That’s a session key / route-like string.

openclaw agent --session-id expects the actual internal sessionId (usually a generated id), not a Discord route.

So your command falls back to the normal --agent main target, which is why it lands in agent:main:main.

Two important fixes

  1. Use --to, not --session-id, for route-derived targeting
  2. --reply-account should probably be default, not main
    (main is your agent id, not the Discord account id)
#

Try this instead

openclaw agent \
  --agent main \
  --to "user:417043254136012801" \
  --reply-channel discord \
  --reply-to "user:417043254136012801" \
  --reply-account default \
  --deliver \
  --message "[message]"

Important nuance

  • this uses route-derived continuity
  • it does not pin an exact sessionKey string the way raw chat.send / sessions.send can
#

So:

  • if you want “target the exact existing session key” → use chat.send / sessions.send
  • if you want “CLI, same DM route, normal continuity” → use openclaw agent --to ...

And yep, per docs:

  • --session-id = explicit session id
  • --to = recipient used to derive the session key
  • reply flags only affect delivery, not session routing

Docs:

If you want, I can help you map your actual Discord DM session key to the best of:

  1. exact chat.send
  2. route-derived openclaw agent
sullen plinth
#

why does --channel discord --to user:417043254136012801 resolve to agent:main:main instead of agent:main:discord:direct:417043254136012801? Is there a flag we're missing, or is this a bug?

worldly jettyBOT
#

I checked the resolver, and this looks like current behavior, not a missing flag.

Why it happens

The CLI path behind openclaw agent does this for --to:

  • register.agent.ts still describes --to as E.164 / phone-style routing
  • the session resolver in src/config/sessions/session-key.ts explicitly says:

All non-group direct chats collapse to this bucket (default: main), groups stay isolated.

So for a direct target like:

--to user:417043254136012801
#

it resolves to the canonical direct-chat bucket:

agent:main:main

not:

agent:main:discord:direct:417043254136012801

So, bug or not?

I’d call it a limitation / mismatch in expectations, and maybe a docs/CLI ergonomics bug.

  • Not a missing flag, as far as I can see
#
  • Not channel-aware DM routing in the current CLI resolver
  • Yes, misleading if you expect Discord DM session continuity from --to

What to use instead

If you need the exact Discord DM session key, use Gateway RPC:

  • chat.send with sessionKey
  • or sessions.send with key

Because those let you target:

agent:main:discord:direct:user:417043254136012801

directly.