Connect an OpenClaw channel
Relay is designed to fit channel gateways such as OpenClaw. The adapter boundary is:
- Relay inbound
message.receivedbecomes a normalized channel turn. conversation.idbecomes 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.
Minimal Node adapter
Section titled “Minimal Node adapter”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");});Mapping
Section titled “Mapping”| 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 |
Media rule
Section titled “Media rule”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.
Streaming
Section titled “Streaming”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.