openclaw pattern intermediate

Ditch Discord as Your Agent Message Bus

Discord is great for humans. For agents talking to agents, you're paying for overhead you don't need — rate limits, external dependency, JSON wrapped in markdown. If both machines are on Tailscale, a FastAPI endpoint and a curl command is the whole architecture.

When you have two AI agents that need to coordinate — one running on your local box, one on a VPS — the first instinct is to route through Discord. It’s already there, it gives you a message log, the agents already live on it. Reasonable.

It’s also slow, rate-limited, and dependent on an external service you don’t control. If Discord goes down, your agents stop talking.

Here’s the simpler architecture: one FastAPI server, one curl command, one shared secret. All of it stays inside your Tailscale network.

The Problem

Using a Discord channel as a message bus between two agents works until it doesn’t. The friction shows up when you start thinking seriously about throughput, latency, and what happens when Discord has an incident. You’re also constrained by Discord’s message format — your structured JSON gets wrapped in markdown text, parsed out on the other end, and hoped for the best.

The real issue: you already have a private network. Tailscale gives every machine a stable IP and a hostname. You’re routing through Discord for machines that can talk directly.

Why This Happens

Discord channels as agent buses are a convenience choice that doesn’t get revisited. The channel was already there, the bot already had permissions, so that’s where the messages went. Nobody stops to ask whether a chat platform is the right substrate for structured JSON payloads between services.

Pattern 1: Tailscale Direct HTTP

The setup:  Tony (chief of staff agent on the home server) needs to send tasks to  Mel (engineering agent on a VPS). Both machines are on Tailscale.

 Mel’s side — the receiver:

# ~/agent-bus/server.py
import json, os, subprocess
from datetime import datetime, timezone
from pathlib import Path
from dotenv import load_dotenv
from fastapi import FastAPI, Header, HTTPException

load_dotenv()

SHARED_SECRET = os.environ["SHARED_SECRET"]
LOG_FILE = Path(__file__).parent / "logs" / "agent-bus.jsonl"
LOG_FILE.parent.mkdir(exist_ok=True)

app = FastAPI()

def log(direction, payload):
    with LOG_FILE.open("a") as f:
        f.write(json.dumps({
            "ts": datetime.now(timezone.utc).isoformat(),
            "direction": direction,
            "payload": payload
        }) + "\n")

@app.get("/health")
def health():
    return {"status": "ok", "agent": "mel"}

@app.post("/message")
async def receive_message(payload: dict, x_shared_secret: str = Header(None)):
    if x_shared_secret != SHARED_SECRET:
        raise HTTPException(status_code=401)
    log("inbound", payload)
    await handle(payload)
    return {"status": "received"}

async def handle(payload):
    msg_type = payload.get("type")
    # route TASK, APPROVAL, etc. to your task processor
    pass

Start it bound to the Tailscale interface — not 0.0.0.0:

# Always use tmux for long-running processes on a VPS
tmux new-session -d -s agent-bus \
  'uvicorn server:app --host your-tailscale-ip --port 8700'

Binding to the Tailscale IP means this endpoint is only reachable from machines on your tailnet. No firewall rules needed.

 Tony’s side — the sender:

source ~/.env
curl -s -X POST "http://mel-tailscale-ip:8700/message" \
  -H "X-Shared-Secret: $SHARED_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"type": "TASK", "payload": {"objective": "..."}}'

The shared secret is a lightweight sanity check — Tailscale is already your security perimeter. You’re not protecting against the internet; you’re protecting against a misconfigured service accidentally routing traffic to the wrong place.

Pattern 2: Injecting Messages into a Remote OpenClaw Agent

The other direction —  Mel sending status updates back to  Tony — is trickier. OpenClaw’s gateway exposes /tools/invoke, but the tools that matter (chat.inject, chat.send) are session-scoped. They’re not available externally.

You can verify this by grepping the gateway dist:

grep -o '"[a-z._]*"' /path/to/openclaw/dist/gateway-cli-*.js \
  | grep -E '"[a-z]+\.[a-z]+"' | sort -u

The tool names are there. They just can’t be invoked via the external HTTP endpoint — they require an active agent session context.

The actual answer is simpler: use the openclaw agent CLI with the remote gateway config.

On  Mel’s machine, configure the remote gateway once:

openclaw config set gateway.remote.url wss://tony-tailscale-hostname
openclaw config set gateway.remote.token your-gateway-token

Then inject a message:

openclaw agent --agent main -m '{"type":"STATUS","from":"mel","payload":{...}}'

That’s a one-liner that routes from  Mel’s VPS into  Tony’s running session over Tailscale.

In Python:

def send_to_tony(message_type: str, payload: dict):
    body = {
        "type": message_type,
        "from": "mel",
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "payload": payload,
    }
    log("outbound", body)
    subprocess.run(
        ["openclaw", "agent", "--agent", "main", "-m", json.dumps(body)],
        check=True,
        timeout=30,
    )

One note: --url is on the openclaw acp command, not openclaw agent. Don’t try to pass a URL directly to agent — configure it once with gateway.remote.url and the CLI picks it up automatically.

Smoke Test

Three checks, all three should pass:

# 1. Health
curl http://mel-tailscale-ip:8700/health
# → {"status":"ok","agent":"mel"}

# 2. Tony → Mel
curl -X POST http://mel-tailscale-ip:8700/message \
  -H "X-Shared-Secret: $SHARED_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"type":"TASK","payload":{"objective":"smoke test"}}'
# → {"status":"received"}

# 3. Mel → Tony (run from Mel's box)
openclaw agent --agent main -m '{"type":"STATUS","from":"mel","payload":{"note":"smoke test"}}'
# → completed

Key Takeaway

If your agents are on Tailscale and you’re routing messages through Discord, you’re adding a third party to a conversation that doesn’t need one. FastAPI on the receiver, curl on the sender, shared secret for sanity, openclaw agent --agent main for the return trip. The audit log is a JSONL file on disk. The architecture fits on a napkin.

The pattern generalizes: any two services on a Tailscale network can communicate this way. The agent-specific piece is the openclaw agent CLI for injecting into a running session — everything else is just HTTP.

FAQ

Why bind the FastAPI server to the Tailscale IP instead of 0.0.0.0?

Binding to the Tailscale IP means the port is only reachable from machines on your tailnet. Binding to 0.0.0.0 exposes it on all interfaces including your public IP. Use the Tailscale IP and skip the firewall configuration entirely.

How do I find what API routes OpenClaw's gateway actually exposes?

Grep the gateway dist files for quoted path strings and tool names: grep -o '"/[a-z/_:-]*"' dist/gateway-cli-*.js | sort -u for routes, and grep -o '"[a-z]+\.[a-z]+"' dist/gateway-cli-*.js | sort -u for tool names. The dist is minified but readable enough for discovery.

Can I use openclaw agent --url to point at a remote gateway?

No — --url is on the openclaw acp command, not openclaw agent. The correct approach for cross-machine injection is openclaw config set gateway.remote.url plus gateway.remote.token, then openclaw agent --agent main routes to the configured remote gateway automatically.

What replaces the Discord audit trail if you drop the channel?

A JSONL file. Every inbound and outbound message gets appended as a JSON object with a timestamp and direction field. It's grep-able, scriptable, and doesn't require an external service.