Skip to content

Connect an OpenClaw channel

Relay is designed to fit channel gateways such as OpenClaw. The adapter boundary is:

  • Relay inbound message.received becomes a normalized channel turn.
  • conversation.id becomes the route peer and session identity.
  • Relay outbound sends use POST /v1/conversations/{conversationId}/messages.
  • The model never chooses the outbound channel. The channel route comes from the inbound event.

This runnable adapter receives a Relay webhook, normalizes it into an OpenClaw-style turn object, and sends a reply to Relay. Replace runAgent(turn) with your OpenClaw dispatch call.

import { createServer } from "node:http";
const relayApiKey = process.env.RELAY_API_KEY;
if (!relayApiKey) throw new Error("Set RELAY_API_KEY");
function normalizeRelayTurn(event) {
return {
input: {
id: event.message.id,
rawText: event.message.fallback_text,
textForAgent: event.message.fallback_text,
rawEvent: event,
},
sender: {
id: event.message.sender.id,
name: event.message.sender.display_name,
isBot: false,
isSelf: false,
displayLabel: event.message.sender.display_name,
},
conversation: {
kind: event.conversation.kind,
id: event.conversation.id,
routePeer: {
channel: "relay",
id: event.conversation.id,
},
},
route: {
agentId: event.agent.handle,
routeSessionKey: `relay:${event.conversation.id}`,
},
replyPlan: {
to: event.conversation.id,
replyTarget: event.conversation.id,
replyToId: event.message.id,
},
message: {
rawBody: event.message.fallback_text,
bodyForAgent: event.message.fallback_text,
preview: event.message.fallback_text,
attachments: event.message.content.filter((block) => block.type !== "text"),
},
};
}
async function runAgent(turn) {
return {
text: `Relay channel received: ${turn.input.textForAgent}`,
};
}
async function sendRelayReply(conversationId, eventId, text) {
const response = await fetch(
`https://api.relay.app/v1/conversations/${conversationId}/messages`,
{
method: "POST",
headers: {
Authorization: `Bearer ${relayApiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": `openclaw-${eventId}`,
},
body: JSON.stringify({
content: [{ type: "text", text }],
fallbackText: text,
}),
},
);
if (!response.ok) {
throw new Error(`Relay send failed: ${response.status} ${await response.text()}`);
}
}
createServer(async (request, response) => {
if (request.method !== "POST" || request.url !== "/relay/webhook") {
response.writeHead(404).end();
return;
}
const chunks = [];
for await (const chunk of request) chunks.push(Buffer.from(chunk));
const event = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
response.writeHead(204).end();
if (event.type !== "message.received") return;
const turn = normalizeRelayTurn(event);
const reply = await runAgent(turn);
await sendRelayReply(event.conversation.id, event.id, reply.text);
}).listen(8787, () => {
console.log("Relay OpenClaw adapter listening on http://localhost:8787/relay/webhook");
});
Relay field OpenClaw-style field
event.message.id NormalizedTurnInput.id
event.message.fallback_text rawText, textForAgent, bodyForAgent
event.message.sender SenderFacts
event.conversation.id ConversationFacts.id, routePeer.id, replyTarget
event.agent.handle RouteFacts.agentId
event.message.reply_to_message_id ReplyPlanFacts.replyToId
non-text blocks supplemental context or attachments

Relay webhook media blocks include signed readable URLs when access is allowed. The adapter should pass those URLs through as attachment facts so the agent can fetch bytes without a Relay-specific media sidecar.

OpenClaw-style gateways can map Relay streaming to their native channel strategy:

  • Send a Relay streamed reply with POST /v1/conversations/{id}/messages?stream=true.
  • Or send a completed Relay message after the OpenClaw run finishes.
  • Or use patch endpoints when the gateway already has an internal edit-preview mechanism.