Skill: sharoonsharif/openclaw-link (version 1.0.2)

--- name: clawlink description: > Cross-instance agent communication for OpenClaw. ClawLink lets multiple OpenClaw sessions discover each other, delegate tasks, share knowledge, collaboratively edit files, and work as a coordinated agent mesh — across different machines on a local network. Use this skill whenever the user mentions connecting OpenClaw instances, multi-agent workflows, agent-to-agent communication, delegating tasks between sessions, collaborative AI work, agent discovery, or running agents on multiple machines. Also trigger when the user says things like "ask my other agent to...", "have another Claude work on...", "set up agent communication", "multi-machine", "agent mesh", "distributed agents", or "ClawLink". If the user wants two or more AI sessions to work together in any way, this is the skill to use. version: 1.0.2 metadata: openclaw: emoji: "🔗" --- # ClawLink — Cross-Instance Agent Communication ClawLink turns isolated OpenClaw sessions into a collaborative agent mesh. Multiple OpenClaw instances — on the same machine or across a network — can delegate tasks, share findings, co-edit files, and coordinate work. > **Security note:** The relay server only runs when you explicitly start it with `python3 server.py`. It stores no data on disk — everything is in-memory and clears on shutdown. When connecting across machines, use `--token` to require authentication. ## Architecture ``` Machine A (OpenClaw) Machine B (OpenClaw) │ │ └──── HTTP/WebSocket ──────────┘ │ ┌───────┴───────┐ │ ClawLink │ │ Relay Server │ │ (localhost or │ │ LAN machine) │ └────────────────┘ ``` One machine runs the relay server. All others connect as agent clients. The relay handles message routing, queuing, and agent registry — entirely in-memory. ## Quick Start ### Step 1: Start the Relay Server On the machine that will act as the hub (can be one of the agent machines): ```bash # Install dependencies (user install — no system changes) pip install --user aiohttp requests # Start the relay — localhost only (safest for single-machine use) python3 scripts/server.py # Or bind to LAN with auth token for multi-machine use python3 scripts/server.py --host 0.0.0.0 --token YOUR_SHARED_SECRET ``` The server defaults to `127.0.0.1` (localhost only). To allow other machines on your local network, add `--host 0.0.0.0 --token YOUR_SECRET`. **For access from another machine on your network:** Use the relay machine's local IP address directly — e.g., `http://192.168.1.10:9077`. Both machines must be on the same network and you must pass the same `--token` on both server and clients. ### Step 2: Register This Agent ```bash python3 /path/to/clawlink/scripts/client.py \ --relay http://RELAY_IP:9077 \ --token YOUR_SHARED_SECRET \ register \ --name "DESCRIPTIVE_NAME" \ --caps "COMMA_SEPARATED_CAPABILITIES" \ --description "What this agent specializes in" ``` The token can also be set via `CLAWLINK_TOKEN` environment variable. ### Step 3: Discover and Communicate ```bash python3 scripts/client.py --relay http://RELAY_IP:9077 --token SECRET discover ``` ## Core Operations ### Discovering Peers ```bash python3 scripts/client.py discover ``` Returns a table of online agents with their IDs, names, capabilities, and machines. ### Delegating Tasks ```bash python3 scripts/client.py delegate \ --to TARGET_AGENT_ID \ --task "Clear description of what needs to be done" \ --context '{"key": "relevant context data"}' \ --priority normal ``` ### Receiving and Responding to Tasks ```bash # Poll for incoming messages python3 scripts/client.py poll # Respond when done python3 scripts/client.py respond \ --to REQUESTING_AGENT_ID \ --msg-id ORIGINAL_MESSAGE_ID \ --result "Task result or summary of work done" ``` ### Broadcasting Knowledge ```bash python3 scripts/client.py broadcast \ --content "Description of the finding" \ --topic "category" \ --tags "tag1,tag2" ``` ### Collaborative File Editing ```bash # Upload/update a shared file python3 scripts/client.py file-put --key "report.md" --file ./report.md # Download a shared file python3 scripts/client.py file-get --key "report.md" --output ./report.md # List shared files python3 scripts/client.py file-list ``` ## Behavioral Guidelines for Agents ### As a Task Receiver 1. **Poll regularly** — Check for messages every 30-60 seconds during active work, or when the user asks "any messages?" or "check ClawLink" 2. **Acknowledge receipt** — Respond with status "in_progress" when you get a task 3. **Be thorough** — Complete the full task before responding 4. **Report failures** — If you can't complete a task, respond with status "failed" and explain why ### As a Task Delegator 1. **Match capabilities** — Use `discover` to find the right agent for the job 2. **Provide context** — Include file paths, URLs, constraints, and output format 3. **Be patient** — The other agent may take time ### General - Always tell the user what's happening on the network - Surface incoming messages proactively - Keep heartbeats alive during long sessions ## Troubleshooting | Problem | Solution | |---------|----------| | "Connection refused" | Check relay is running and IP/port are correct | | "Unauthorized" | Pass the same --token (or CLAWLINK_TOKEN) as the server | | Can't find relay on LAN | Use explicit `--relay http://IP:PORT` | | Messages not arriving | Run heartbeat to re-register; check agent_id matches | | Agent shows "stale" | Agent hasn't heartbeated in 120s — restart or heartbeat | ## Protocol Reference For the full message format specification, transport layer details, and workflow patterns, read `references/protocol.md`.

path: README.md
content: 
**Turn isolated AI sessions into a collaborative agent mesh.**

ClawLink lets multiple OpenClaw (or Claude Code) sessions discover each other, delegate tasks, share knowledge, and collaboratively edit files — across different machines on a LAN or across the internet.

## Why ClawLink?

Every OpenClaw session is a powerful AI agent — but they're isolated. ClawLink breaks that barrier:

- 🔍 **Agent Discovery** — See who's online and what they can do
- 📋 **Task Delegation** — Ask another agent to handle a subtask
- 📡 **Knowledge Broadcast** — Share findings with the entire mesh
- 📝 **Collaborative Files** — Co-author documents across agents
- ⚡ **Real-time + Fallback** — WebSocket when possible, HTTP polling always works
- 🌐 **LAN Auto-discovery** — Zero-config via mDNS/Zeroconf
- 🚇 **Internet-ready** — Works through ngrok or Cloudflare Tunnel

## Quick Start

### 1. Install

```bash
# As an OpenClaw skill
# Drop the clawlink/ folder into your skills directory

# Dependencies
pip install aiohttp requests
pip install zeroconf  # optional, for LAN auto-discovery
```

### 2. Start the Relay (one machine)

```bash
python3 scripts/server.py --host 0.0.0.0
```

### 3. Connect Agents (any machine)

```bash
# Machine A
python3 scripts/client.py --relay http://RELAY_IP:9077 \
  register --name "researcher" --caps "search,summarize"

# Machine B
python3 scripts/client.py --relay http://RELAY_IP:9077 \
  register --name "coder" --caps "code,debug,test"
```

### 4. Collaborate

```bash
# See who's online
python3 scripts/client.py discover

# Delegate a task
python3 scripts/client.py delegate --to AGENT_ID --task "Find papers on CIM"

# Share a finding
python3 scripts/client.py broadcast --content "CIM reduces DRAM access by 94%"

# Co-edit a file
python3 scripts/client.py file-put --key "report.md" --file ./report.md
```

## Architecture

```
  Machine A (OpenClaw)       Machine B (OpenClaw)       Machine C (OpenClaw)
       │                           │                           │
       └──── HTTP / WebSocket ─────┼──── HTTP / WebSocket ─────┘
                                   │
                           ┌───────┴───────┐
                           │  ClawLink      │
                           │  Relay Server  │
                           │  (port 9077)   │
                           └────────────────┘
```

## Transport Layers

| Transport | Use Case | Setup |
|-----------|----------|-------|
| **HTTP REST** | Universal, works everywhere | Zero config |
| **WebSocket** | Real-time push (<100ms) | Automatic upgrade |
| **mDNS** | Zero-config LAN discovery | `pip install zeroconf` |
| **Tunnel** | Internet-wide access | `ngrok http 9077` |

## Use Cases

- **Parallel Research** — Multiple agents research different subtopics simultaneously
- **Code Review Pipeline** — One agent writes code, another reviews, a third writes tests
- **Distributed Debugging** — Agents on different machines investigate different parts of a system
- **Document Co-authoring** — Research agent finds sources, writer agent drafts, editor agent polishes
- **Knowledge Mesh** — Agents broadcast discoveries so the whole team stays informed

## License

MIT

---

*Built for the OpenClaw community. Break the isolation barrier.* 🔗


path: references/protocol.md
content: 
## Overview

ClawLink uses a hub-and-spoke topology: a single **relay server** acts as the switchboard, and **agents** (OpenClaw sessions) connect as clients. The relay handles routing, queuing, and state — agents are stateless between polls.

## Transport Layers

ClawLink supports three transports, auto-negotiated by capability:

| Transport | Use Case | Latency | Setup |
|-----------|----------|---------|-------|
| HTTP REST | Universal fallback, works through firewalls | Poll-based (~1-5s) | Zero config |
| WebSocket | Real-time push when both sides support it | Instant (<100ms) | Same server |
| mDNS/Zeroconf | Zero-config LAN discovery of relay servers | N/A (discovery only) | `pip install zeroconf` |

For internet-wide access, expose the relay via **ngrok** (`ngrok http 9077`) or **Cloudflare Tunnel** (`cloudflared tunnel --url localhost:9077`).

## Message Types

### 1. Registration
```json
{
  "agent_id": "a1b2c3d4",
  "name": "researcher",
  "capabilities": ["search", "summarize", "analyze"],
  "machine": "hostname",
  "description": "Finds and summarizes academic papers"
}
```

### 2. Task Delegation
```json
{
  "type": "task_delegation",
  "message_id": "msg-abc123",
  "from_agent": "a1b2c3d4",
  "to_agent": "e5f6g7h8",
  "task": "Find the top 3 papers on neuromorphic computing from 2025",
  "context": {
    "related_to": "SpikeCache project",
    "output_format": "markdown summary"
  },
  "priority": "high"
}
```

### 3. Task Response
```json
{
  "type": "task_response",
  "message_id": "msg-abc123",
  "from_agent": "e5f6g7h8",
  "to_agent": "a1b2c3d4",
  "result": "Found 3 papers: ...",
  "status": "completed"
}
```

### 4. Knowledge Broadcast
```json
{
  "type": "knowledge_broadcast",
  "from_agent": "a1b2c3d4",
  "content": "Key finding: CIM architectures reduce DRAM access by 94% for attention layers",
  "topic": "research",
  "tags": ["CIM", "attention", "memory"]
}
```

### 5. Collaborative File Operations
```json
{
  "file_key": "shared-report.md",
  "content": "# Report\n\n## Findings\n...",
  "file_type": "text",
  "agent_id": "a1b2c3d4"
}
```

## Agent Capabilities Registry

Agents declare capabilities at registration. Common capability tags:

| Capability | Description |
|-----------|-------------|
| `code` | Can write and execute code |
| `search` | Can search the web or databases |
| `summarize` | Can summarize long content |
| `analyze` | Can perform data analysis |
| `write` | Can produce documents and reports |
| `review` | Can review and critique work |
| `debug` | Can diagnose and fix code issues |
| `translate` | Can translate between languages |
| `design` | Can create visual designs |
| `test` | Can write and run tests |

## Workflow Patterns

### Pattern 1: Research Pipeline
```
Agent A (coordinator) → delegates "search papers on X" → Agent B (researcher)
Agent B → broadcasts findings → All agents
Agent A → delegates "write summary from findings" → Agent C (writer)
Agent C → file-put "report.md" → Shared file store
```

### Pattern 2: Distributed Code Review
```
Agent A → file-put "code.py" → Shared file store
Agent A → delegates "review code.py for bugs" → Agent B
Agent A → delegates "review code.py for performance" → Agent C
Agent B → responds with bug report → Agent A
Agent C → responds with perf report → Agent A
Agent A → synthesizes and file-put "review-summary.md"
```

### Pattern 3: Parallel Research
```
Coordinator → delegates "research topic A" → Agent 1
Coordinator → delegates "research topic B" → Agent 2
Coordinator → delegates "research topic C" → Agent 3
[All agents broadcast findings as they discover them]
Coordinator → polls all results → Synthesizes final report
```

## Error Handling

| Scenario | Behavior |
|----------|----------|
| Agent goes offline | Messages queued for up to 120s, then agent marked stale |
| Relay unreachable | Client retries with exponential backoff |
| WebSocket drops | Falls back to HTTP polling automatically |
| mDNS unavailable | Use explicit relay URL instead |

## Security Notes

- ClawLink is designed for **trusted networks** (home, office, lab)
- No authentication by default — all agents on the network can join
- For production/internet use, put the relay behind a reverse proxy with auth
- Messages are not encrypted in transit — use HTTPS/WSS via reverse proxy if needed


path: scripts/client.py
content: """
ClawLink Client — CLI + library for OpenClaw agents to join the ClawLink network.

Pass --token (or set CLAWLINK_TOKEN env var) to match the relay server's token.

Usage:
  python client.py --relay http://HOST:9077 --token SECRET register --name "researcher" --caps "search,summarize"
  python client.py discover
  python client.py delegate --to <agent_id> --task "Find recent papers on X"
  python client.py broadcast --topic "finding" --content "Result: ..."
  python client.py poll
  python client.py respond --to <agent_id> --msg-id <id> --result "Done"
  python client.py file-put --key "report.md" --file ./report.md
  python client.py file-get --key "report.md"
  python client.py file-list
  python client.py scan         # mDNS scan for relay servers on LAN
"""

import argparse
import json
import os
import sys
import time
import socket
from typing import Optional

try:
    import requests
    HAS_REQUESTS = True
except ImportError:
    HAS_REQUESTS = False

try:
    from zeroconf import ServiceBrowser, Zeroconf, ServiceStateChange
    HAS_ZEROCONF = True
except ImportError:
    HAS_ZEROCONF = False


# ── State file for persistent agent identity ────────────────────────────────

STATE_DIR = os.path.expanduser("~/.clawlink")
STATE_FILE = os.path.join(STATE_DIR, "agent_state.json")


def load_state() -> dict:
    if os.path.exists(STATE_FILE):
        with open(STATE_FILE, "r") as f:
            return json.load(f)
    return {}


def save_state(state: dict):
    os.makedirs(STATE_DIR, exist_ok=True)
    with open(STATE_FILE, "w") as f:
        json.dump(state, f, indent=2)


# ── mDNS Scanner ────────────────────────────────────────────────────────────

def scan_for_relays(timeout: float = 5.0) -> list:
    """Scan LAN for ClawLink relay servers via mDNS."""
    if not HAS_ZEROCONF:
        print("zeroconf not installed. Install with: pip install --user zeroconf")
        return []

    found = []

    class Listener:
        def add_service(self, zc, type_, name):
            info = zc.get_service_info(type_, name)
            if info:
                addresses = [socket.inet_ntoa(a) for a in info.addresses]
                for addr in addresses:
                    url = f"http://{addr}:{info.port}"
                    props = {
                        k.decode() if isinstance(k, bytes) else k:
                        v.decode() if isinstance(v, bytes) else v
                        for k, v in info.properties.items()
                    }
                    found.append({
                        "name": name,
                        "url": url,
                        "host": addr,
                        "port": info.port,
                        "properties": props
                    })

        def remove_service(self, zc, type_, name):
            pass

        def update_service(self, zc, type_, name):
            pass

    zc = Zeroconf()
    listener = Listener()
    browser = ServiceBrowser(zc, "_clawlink._tcp.local.", listener)

    print(f"Scanning for ClawLink relays on LAN ({timeout}s)...")
    time.sleep(timeout)
    zc.close()

    return found


# ── Client Library ──────────────────────────────────────────────────────────

class ClawLinkClient:
    """Python client for interacting with a ClawLink relay server."""

    def __init__(self, relay_url: str = None, token: str = None):
        if not HAS_REQUESTS:
            raise RuntimeError("requests library required: pip install --user requests")

        if relay_url:
            self.relay_url = relay_url.rstrip("/")
        else:
            self.relay_url = self._auto_discover()

        # Token from arg, env var, or saved state
        self.token = (
            token
            or os.environ.get("CLAWLINK_TOKEN")
        )

        self.agent_id = None
        self.agent_name = None

        state = load_state()
        if state.get("relay_url") == self.relay_url:
            self.agent_id = state.get("agent_id")
            self.agent_name = state.get("agent_name")

    def _auto_discover(self) -> str:
        relays = scan_for_relays(timeout=3.0) if HAS_ZEROCONF else []
        if relays:
            url = relays[0]["url"]
            print(f"Auto-discovered relay: {url}")
            return url
        return "http://localhost:9077"

    def _headers(self) -> dict:
        h = {"Content-Type": "application/json"}
        if self.token:
            h["Authorization"] = f"Bearer {self.token}"
        return h

    def _post(self, endpoint: str, data: dict) -> dict:
        resp = requests.post(
            f"{self.relay_url}/{endpoint}",
            json=data,
            headers=self._headers(),
            timeout=10
        )
        resp.raise_for_status()
        return resp.json()

    def _get(self, endpoint: str) -> dict:
        resp = requests.get(
            f"{self.relay_url}/{endpoint}",
            headers=self._headers(),
            timeout=10
        )
        resp.raise_for_status()
        return resp.json()

    def register(self, name: str, capabilities: list = None,
                 description: str = "") -> dict:
        import uuid
        agent_id = str(uuid.uuid4())[:8]
        result = self._post("register", {
            "agent_id": agent_id,
            "name": name,
            "capabilities": capabilities or [],
            "machine": socket.gethostname(),
            "description": description
        })
        self.agent_id = result["agent"]["agent_id"]
        self.agent_name = name

        save_state({
            "relay_url": self.relay_url,
            "agent_id": self.agent_id,
            "agent_name": self.agent_name
        })

        return result

    def discover(self) -> list:
        return self._get("discover").get("agents", [])

    def heartbeat(self) -> dict:
        return self._post("heartbeat", {"agent_id": self.agent_id})

    def delegate(self, to_agent: str, task: str, context: dict = None,
                 priority: str = "normal") -> dict:
        return self._post("delegate", {
            "from_agent": self.agent_id,
            "to_agent": to_agent,
            "task": task,
            "context": context or {},
            "priority": priority
        })

    def broadcast(self, content: str, topic: str = "general",
                  tags: list = None) -> dict:
        return self._post("broadcast", {
            "from_agent": self.agent_id,
            "content": content,
            "topic": topic,
            "tags": tags or []
        })

    def poll(self) -> list:
        return self._get(f"poll/{self.agent_id}").get("messages", [])

    def respond(self, to_agent: str, message_id: str, result: str,
                status: str = "completed") -> dict:
        return self._post("respond", {
            "from_agent": self.agent_id,
            "to_agent": to_agent,
            "message_id": message_id,
            "result": result,
            "status": status
        })

    def file_put(self, file_key: str, content: str,
                 file_type: str = "text") -> dict:
        return self._post("files", {
            "file_key": file_key,
            "content": content,
            "agent_id": self.agent_id,
            "file_type": file_type
        })

    def file_get(self, file_key: str) -> dict:
        return self._get(f"files/{file_key}")

    def file_list(self) -> list:
        return self._get("files").get("files", [])

    def info(self) -> dict:
        return self._get("")


# ── CLI Interface ───────────────────────────────────────────────────────────

def cli():
    parser = argparse.ArgumentParser(
        description="ClawLink Client — Connect your OpenClaw agent to the mesh"
    )
    parser.add_argument("--relay", default=None,
                        help="Relay server URL (default: auto-discover or localhost:9077)")
    parser.add_argument("--token", default=None,
                        help="Auth token (or set CLAWLINK_TOKEN env var)")

    sub = parser.add_subparsers(dest="command")

    reg = sub.add_parser("register", help="Join the ClawLink network")
    reg.add_argument("--name", required=True)
    reg.add_argument("--caps", default="")
    reg.add_argument("--description", default="")

    sub.add_parser("discover", help="List online agents")
    sub.add_parser("heartbeat", help="Send heartbeat")

    dlg = sub.add_parser("delegate", help="Delegate a task to another agent")
    dlg.add_argument("--to", required=True)
    dlg.add_argument("--task", required=True)
    dlg.add_argument("--context", default="{}")
    dlg.add_argument("--priority", default="normal", choices=["low", "normal", "high", "urgent"])

    bcast = sub.add_parser("broadcast", help="Broadcast knowledge")
    bcast.add_argument("--content", required=True)
    bcast.add_argument("--topic", default="general")
    bcast.add_argument("--tags", default="")

    sub.add_parser("poll", help="Poll for incoming messages")

    rsp = sub.add_parser("respond", help="Respond to a delegated task")
    rsp.add_argument("--to", required=True)
    rsp.add_argument("--msg-id", required=True)
    rsp.add_argument("--result", required=True)
    rsp.add_argument("--status", default="completed")

    fp = sub.add_parser("file-put", help="Create/update a shared file")
    fp.add_argument("--key", required=True)
    fp.add_argument("--file", required=True)
    fp.add_argument("--type", default="text")

    fg = sub.add_parser("file-get", help="Retrieve a shared file")
    fg.add_argument("--key", required=True)
    fg.add_argument("--output", default=None)

    sub.add_parser("file-list", help="List shared files")
    sub.add_parser("scan", help="Scan LAN for ClawLink relays via mDNS")
    sub.add_parser("info", help="Get relay server info")

    args = parser.parse_args()

    if not args.command:
        parser.print_help()
        return

    if args.command == "scan":
        relays = scan_for_relays()
        if relays:
            print(f"\nFound {len(relays)} relay(s):")
            for r in relays:
                auth = r["properties"].get("auth", "unknown")
                print(f"  {r['url']}  ({r['name']})  auth={auth}")
        else:
            print("No relays found on LAN.")
        return

    client = ClawLinkClient(args.relay, args.token)

    if args.command == "register":
        caps = [c.strip() for c in args.caps.split(",") if c.strip()]
        result = client.register(args.name, caps, args.description)
        print(json.dumps(result, indent=2))

    elif args.command == "discover":
        agents = client.discover()
        if agents:
            print(f"\n{'Name':<20} {'ID':<10} {'Capabilities':<30} {'Machine':<15} {'Status'}")
            print("─" * 90)
            for a in agents:
                caps = ", ".join(a.get("capabilities", []))
                print(f"{a['name']:<20} {a['agent_id']:<10} {caps:<30} {a.get('machine',''):<15} {a['status']}")
        else:
            print("No agents online.")

    elif args.command == "heartbeat":
        print(json.dumps(client.heartbeat(), indent=2))

    elif args.command == "delegate":
        ctx = json.loads(args.context) if args.context != "{}" else {}
        print(json.dumps(client.delegate(args.to, args.task, ctx, args.priority), indent=2))

    elif args.command == "broadcast":
        tags = [t.strip() for t in args.tags.split(",") if t.strip()]
        print(json.dumps(client.broadcast(args.content, args.topic, tags), indent=2))

    elif args.command == "poll":
        messages = client.poll()
        if messages:
            for m in messages:
                print(f"\n[{m.get('type', 'unknown')}] from {m.get('from_agent', '?')}")
                if m.get("task"):
                    print(f"  Task: {m['task']}")
                if m.get("content"):
                    print(f"  Content: {m['content']}")
                if m.get("result"):
                    print(f"  Result: {m['result']}")
        else:
            print("No new messages.")

    elif args.command == "respond":
        print(json.dumps(client.respond(args.to, args.msg_id, args.result, args.status), indent=2))

    elif args.command == "file-put":
        with open(args.file, "r") as f:
            content = f.read()
        print(json.dumps(client.file_put(args.key, content, args.type), indent=2))

    elif args.command == "file-get":
        data = client.file_get(args.key)
        if args.output:
            with open(args.output, "w") as f:
                f.write(data.get("content", ""))
            print(f"Saved to {args.output}")
        else:
            print(data.get("content", ""))

    elif args.command == "file-list":
        files = client.file_list()
        if files:
            print(f"\n{'Key':<30} {'Version':<10} {'Editor':<15} {'Updated'}")
            print("─" * 75)
            for f in files:
                print(f"{f['file_key']:<30} v{f['version']:<9} {f.get('last_editor',''):<15} {f.get('updated_at','')[:19]}")
        else:
            print("No shared files.")

    elif args.command == "info":
        print(json.dumps(client.info(), indent=2))


if __name__ == "__main__":
    cli()


path: scripts/server.py
content: """
ClawLink Relay Server — Message router for coordinating OpenClaw agent sessions.

All data is in-memory only — nothing is written to disk. The server shuts down
cleanly with Ctrl-C and leaves no persistent state.

When --token is set, every request must include the Authorization header:
  Authorization: Bearer <token>
This prevents unintended parties from joining your relay.

Usage:
  python server.py                           # localhost only, no auth
  python server.py --host 0.0.0.0 --token X  # LAN access with auth token
  python server.py --port 8080               # Custom port
  python server.py --no-mdns                 # Disable LAN auto-discovery
"""

import argparse
import asyncio
import json
import time
import uuid
import socket
import threading
from datetime import datetime, timezone
from typing import Dict, Optional

try:
    from aiohttp import web
    HAS_AIOHTTP = True
except ImportError:
    HAS_AIOHTTP = False

try:
    from zeroconf import ServiceInfo, Zeroconf
    HAS_ZEROCONF = True
except ImportError:
    HAS_ZEROCONF = False


# ── Auth Middleware ─────────────────────────────────────────────────────────

SHARED_TOKEN: Optional[str] = None  # Set by --token argument


@web.middleware
async def auth_middleware(request, handler):
    """Require Bearer token when SHARED_TOKEN is configured."""
    if SHARED_TOKEN is None:
        return await handler(request)

    # WebSocket upgrade passes token as query param (?token=X) or header
    token = None
    auth_header = request.headers.get("Authorization", "")
    if auth_header.startswith("Bearer "):
        [REDACTED_SECRET]
    elif "token" in request.rel_url.query:
        [REDACTED_SECRET]"token"]

    if token != SHARED_TOKEN:
        return web.json_response(
            {"error": "Unauthorized. Provide Authorization: Bearer <token>"},
            status=401
        )

    return await handler(request)


# ── Agent Registry ──────────────────────────────────────────────────────────

class AgentRegistry:
    """In-memory registry of connected agents. No disk writes."""

    def __init__(self):
        self.agents: Dict[str, dict] = {}
        self.message_queues: Dict[str, list] = {}
        self.broadcast_log: list = []
        self.ws_connections: Dict[str, web.WebSocketResponse] = {}
        self.file_store: Dict[str, dict] = {}

    def register(self, agent_id: str, name: str, capabilities: list,
                 machine: str = "", description: str = "") -> dict:
        agent = {
            "agent_id": agent_id,
            "name": name,
            "capabilities": capabilities,
            "machine": machine,
            "description": description,
            "registered_at": datetime.now(timezone.utc).isoformat(),
            "last_heartbeat": time.time(),
            "status": "online"
        }
        self.agents[agent_id] = agent
        if agent_id not in self.message_queues:
            self.message_queues[agent_id] = []
        return agent

    def heartbeat(self, agent_id: str) -> bool:
        if agent_id in self.agents:
            self.agents[agent_id]["last_heartbeat"] = time.time()
            self.agents[agent_id]["status"] = "online"
            return True
        return False

    def deregister(self, agent_id: str):
        self.agents.pop(agent_id, None)
        self.message_queues.pop(agent_id, None)
        self.ws_connections.pop(agent_id, None)

    def get_online_agents(self, stale_seconds: float = 120.0) -> list:
        now = time.time()
        online = []
        for aid, agent in self.agents.items():
            if now - agent["last_heartbeat"] < stale_seconds:
                agent["status"] = "online"
                online.append(agent)
            else:
                agent["status"] = "stale"
        return online

    def enqueue_message(self, to_agent: str, message: dict):
        if to_agent not in self.message_queues:
            self.message_queues[to_agent] = []
        message["queued_at"] = datetime.now(timezone.utc).isoformat()
        self.message_queues[to_agent].append(message)

    def poll_messages(self, agent_id: str) -> list:
        msgs = self.message_queues.get(agent_id, [])
        self.message_queues[agent_id] = []
        return msgs


registry = AgentRegistry()


# ── HTTP REST API ───────────────────────────────────────────────────────────

async def handle_register(request):
    data = await request.json()
    agent_id = data.get("agent_id", str(uuid.uuid4())[:8])
    name = data.get("name", f"agent-{agent_id[:4]}")
    capabilities = data.get("capabilities", [])
    machine = data.get("machine", request.remote or "unknown")
    description = data.get("description", "")

    agent = registry.register(agent_id, name, capabilities, machine, description)

    join_msg = {
        "type": "agent_joined",
        "agent": agent,
        "timestamp": datetime.now(timezone.utc).isoformat()
    }
    await broadcast_to_websockets(join_msg, exclude=agent_id)

    return web.json_response({
        "status": "registered",
        "agent": agent,
        "server_time": datetime.now(timezone.utc).isoformat()
    })


async def handle_discover(request):
    agents = registry.get_online_agents()
    return web.json_response({
        "agents": agents,
        "count": len(agents),
        "server_time": datetime.now(timezone.utc).isoformat()
    })


async def handle_heartbeat(request):
    data = await request.json()
    agent_id = data.get("agent_id")
    if not agent_id:
        return web.json_response({"error": "agent_id required"}, status=400)
    ok = registry.heartbeat(agent_id)
    return web.json_response({"status": "ok" if ok else "unknown_agent"})


async def handle_delegate(request):
    data = await request.json()
    from_agent = data.get("from_agent")
    to_agent = data.get("to_agent")
    task = data.get("task")
    context = data.get("context", {})
    priority = data.get("priority", "normal")

    if not all([from_agent, to_agent, task]):
        return web.json_response(
            {"error": "from_agent, to_agent, and task are required"}, status=400
        )

    message = {
        "type": "task_delegation",
        "message_id": str(uuid.uuid4())[:12],
        "from_agent": from_agent,
        "to_agent": to_agent,
        "task": task,
        "context": context,
        "priority": priority,
        "timestamp": datetime.now(timezone.utc).isoformat()
    }

    ws = registry.ws_connections.get(to_agent)
    if ws and not ws.closed:
        await ws.send_json(message)
        delivery = "websocket"
    else:
        registry.enqueue_message(to_agent, message)
        delivery = "queued"

    return web.json_response({
        "status": "delegated",
        "delivery": delivery,
        "message_id": message["message_id"]
    })


async def handle_broadcast(request):
    data = await request.json()
    from_agent = data.get("from_agent")
    content = data.get("content")
    topic = data.get("topic", "general")
    tags = data.get("tags", [])

    if not all([from_agent, content]):
        return web.json_response(
            {"error": "from_agent and content required"}, status=400
        )

    message = {
        "type": "knowledge_broadcast",
        "message_id": str(uuid.uuid4())[:12],
        "from_agent": from_agent,
        "content": content,
        "topic": topic,
        "tags": tags,
        "timestamp": datetime.now(timezone.utc).isoformat()
    }

    registry.broadcast_log.append(message)
    if len(registry.broadcast_log) > 200:
        registry.broadcast_log = registry.broadcast_log[-200:]

    await broadcast_to_websockets(message, exclude=from_agent)

    for aid in registry.agents:
        if aid != from_agent:
            registry.enqueue_message(aid, message)

    return web.json_response({
        "status": "broadcast_sent",
        "message_id": message["message_id"],
        "recipients": len(registry.agents) - 1
    })


async def handle_poll(request):
    agent_id = request.match_info["agent_id"]
    registry.heartbeat(agent_id)
    messages = registry.poll_messages(agent_id)
    return web.json_response({
        "messages": messages,
        "count": len(messages)
    })


async def handle_respond(request):
    data = await request.json()
    from_agent = data.get("from_agent")
    to_agent = data.get("to_agent")
    message_id = data.get("message_id")
    result = data.get("result")
    status = data.get("status", "completed")

    message = {
        "type": "task_response",
        "message_id": message_id,
        "response_id": str(uuid.uuid4())[:12],
        "from_agent": from_agent,
        "to_agent": to_agent,
        "result": result,
        "status": status,
        "timestamp": datetime.now(timezone.utc).isoformat()
    }

    ws = registry.ws_connections.get(to_agent)
    if ws and not ws.closed:
        await ws.send_json(message)
        delivery = "websocket"
    else:
        registry.enqueue_message(to_agent, message)
        delivery = "queued"

    return web.json_response({"status": "response_sent", "delivery": delivery})


async def handle_file_put(request):
    data = await request.json()
    file_key = data.get("file_key")
    content = data.get("content")
    agent_id = data.get("agent_id")
    file_type = data.get("file_type", "text")

    if not all([file_key, content, agent_id]):
        return web.json_response(
            {"error": "file_key, content, and agent_id required"}, status=400
        )

    version = registry.file_store.get(file_key, {}).get("version", 0) + 1

    registry.file_store[file_key] = {
        "file_key": file_key,
        "content": content,
        "file_type": file_type,
        "last_editor": agent_id,
        "version": version,
        "updated_at": datetime.now(timezone.utc).isoformat()
    }

    notify = {
        "type": "file_updated",
        "file_key": file_key,
        "version": version,
        "last_editor": agent_id,
        "timestamp": datetime.now(timezone.utc).isoformat()
    }
    await broadcast_to_websockets(notify, exclude=agent_id)
    for aid in registry.agents:
        if aid != agent_id:
            registry.enqueue_message(aid, notify)

    return web.json_response({"status": "saved", "version": version})


async def handle_file_get(request):
    file_key = request.match_info["file_key"]
    file_data = registry.file_store.get(file_key)
    if not file_data:
        return web.json_response({"error": "file not found"}, status=404)
    return web.json_response(file_data)


async def handle_file_list(request):
    files = [
        {k: v for k, v in f.items() if k != "content"}
        for f in registry.file_store.values()
    ]
    return web.json_response({"files": files, "count": len(files)})


async def handle_websocket(request):
    agent_id = request.match_info["agent_id"]
    ws = web.WebSocketResponse()
    await ws.prepare(request)

    registry.ws_connections[agent_id] = ws
    registry.heartbeat(agent_id)

    print(f"[ClawLink] WebSocket connected: {agent_id}")

    try:
        async for msg in ws:
            if msg.type == web.WSMsgType.TEXT:
                data = json.loads(msg.data)
                msg_type = data.get("type")

                if msg_type == "heartbeat":
                    registry.heartbeat(agent_id)
                elif msg_type == "delegate":
                    data["from_agent"] = agent_id
                    to_agent = data.get("to_agent")
                    target_ws = registry.ws_connections.get(to_agent)
                    if target_ws and not target_ws.closed:
                        await target_ws.send_json(data)
                    else:
                        registry.enqueue_message(to_agent, data)
                elif msg_type == "broadcast":
                    data["from_agent"] = agent_id
                    data["timestamp"] = datetime.now(timezone.utc).isoformat()
                    await broadcast_to_websockets(data, exclude=agent_id)
                    for aid in registry.agents:
                        if aid != agent_id:
                            registry.enqueue_message(aid, data)

            elif msg.type == web.WSMsgType.ERROR:
                print(f"[ClawLink] WS error for {agent_id}: {ws.exception()}")
    finally:
        registry.ws_connections.pop(agent_id, None)
        print(f"[ClawLink] WebSocket disconnected: {agent_id}")

    return ws


async def broadcast_to_websockets(message: dict, exclude: str = ""):
    for aid, ws in list(registry.ws_connections.items()):
        if aid != exclude and not ws.closed:
            try:
                await ws.send_json(message)
            except Exception:
                registry.ws_connections.pop(aid, None)


async def handle_info(request):
    return web.json_response({
        "service": "ClawLink Relay",
        "version": "1.0.1",
        "auth_required": SHARED_TOKEN is not None,
        "agents_online": len(registry.get_online_agents()),
        "total_broadcasts": len(registry.broadcast_log),
        "shared_files": len(registry.file_store),
        "note": "All data is in-memory only. No disk writes. Stops cleanly with Ctrl-C.",
        "endpoints": {
            "register": "POST /register",
            "discover": "GET /discover",
            "heartbeat": "POST /heartbeat",
            "delegate": "POST /delegate",
            "broadcast": "POST /broadcast",
            "respond": "POST /respond",
            "poll": "GET /poll/<agent_id>",
            "files_put": "POST /files",
            "files_get": "GET /files/<file_key>",
            "files_list": "GET /files",
            "websocket": "GET /ws/<agent_id>"
        },
        "server_time": datetime.now(timezone.utc).isoformat()
    })


# ── mDNS/Zeroconf Broadcast ────────────────────────────────────────────────

def get_local_ip():
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.connect(("8.8.8.8", 80))
        ip = s.getsockname()[0]
        s.close()
        return ip
    except Exception:
        return "127.0.0.1"


def start_mdns(port: int) -> Optional['Zeroconf']:
    if not HAS_ZEROCONF:
        print("[ClawLink] zeroconf not installed — skipping mDNS broadcast")
        return None

    local_ip = get_local_ip()
    hostname = socket.gethostname()

    info = ServiceInfo(
        "_clawlink._tcp.local.",
        f"ClawLink Relay ({hostname})._clawlink._tcp.local.",
        addresses=[socket.inet_aton(local_ip)],
        port=port,
        properties={
            "version": "1.0.1",
            "hostname": hostname,
            "auth": "yes" if SHARED_TOKEN else "no",
        },
        server=f"{hostname}.local.",
    )

    zc = Zeroconf()
    zc.register_service(info)
    print(f"[ClawLink] mDNS broadcasting on {local_ip}:{port}")
    return zc


# ── Main ────────────────────────────────────────────────────────────────────

def create_app():
    app = web.Application(middlewares=[auth_middleware])
    app.router.add_get("/", handle_info)
    app.router.add_post("/register", handle_register)
    app.router.add_get("/discover", handle_discover)
    app.router.add_post("/heartbeat", handle_heartbeat)
    app.router.add_post("/delegate", handle_delegate)
    app.router.add_post("/broadcast", handle_broadcast)
    app.router.add_post("/respond", handle_respond)
    app.router.add_get("/poll/{agent_id}", handle_poll)
    app.router.add_post("/files", handle_file_put)
    app.router.add_get("/files", handle_file_list)
    app.router.add_get("/files/{file_key}", handle_file_get)
    app.router.add_get("/ws/{agent_id}", handle_websocket)
    return app


def main():
    global SHARED_TOKEN

    if not HAS_AIOHTTP:
        print("ERROR: aiohttp is required. Install with: pip install --user aiohttp")
        return

    parser = argparse.ArgumentParser(description="ClawLink Relay Server")
    parser.add_argument("--host", default="127.0.0.1",
                        help="Bind address (default: 127.0.0.1 localhost only; use 0.0.0.0 for LAN)")
    parser.add_argument("--port", type=int, default=9077, help="Port (default: 9077)")
    parser.add_argument("--no-mdns", action="store_true", help="Disable mDNS broadcast")
    parser.add_argument("--token", default=None,
                        help="Shared auth token. Required when --host 0.0.0.0 is used.")
    args = parser.parse_args()

    # Warn if opening to network without auth
    if args.host == "0.0.0.0" and not args.[REDACTED_SECRET]"WARNING: Binding to all interfaces without --token.")
        print("         Any machine on the network can connect.")
        print("         Use --token YOUR_SECRET to require authentication.")
        print()

    SHARED_TOKEN = args.token

    zc = None
    if not args.no_mdns and args.host != "127.0.0.1":
        zc = start_mdns(args.port)

    local_ip = get_local_ip() if args.host == "0.0.0.0" else "localhost"
    auth_note = f"token required" if SHARED_TOKEN else "no auth (localhost only)"

    print(f"""
╔══════════════════════════════════════════════════════╗
║              🔗 ClawLink Relay Server                ║
╠══════════════════════════════════════════════════════╣
║  HTTP API:    http://{local_ip}:{args.port:<5}                  ║
║  WebSocket:   ws://{local_ip}:{args.port:<5}/ws/<agent_id>     ║
║  Auth:        {auth_note:<38} ║
║  Storage:     in-memory only (no disk writes)        ║
║  mDNS:        {"ACTIVE" if zc else "DISABLED":<46} ║
╠══════════════════════════════════════════════════════╣
║  Connect agents with: python3 client.py register     ║
╚══════════════════════════════════════════════════════╝
""")

    app = create_app()

    try:
        web.run_app(app, host=args.host, port=args.port, print=lambda _: None)
    except KeyboardInterrupt:
        pass
    finally:
        if zc:
            zc.unregister_all_services()
            zc.close()
        print("[ClawLink] Server stopped. All in-memory data cleared.")


if __name__ == "__main__":
    main()


path: scripts/setup.sh
content: # ClawLink Setup — Install dependencies for the ClawLink agent mesh
# Usage: bash setup.sh [server|client|both]

set -e

MODE="${1:-both}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

echo "╔══════════════════════════════════════════════╗"
echo "║          🔗 ClawLink Setup                   ║"
echo "╠══════════════════════════════════════════════╣"
echo "║  Mode: $MODE                                 "
echo "╚══════════════════════════════════════════════╝"
echo ""

# Check Python
if ! command -v python3 &> /dev/null; then
    echo "ERROR: Python 3 is required but not found."
    exit 1
fi

# Install dependencies into user site-packages to avoid system changes
echo "[1/3] Installing dependencies (user install)..."
pip install --user aiohttp requests || {
    echo "  User install failed. Trying venv approach:"
    echo "  Run: python3 -m venv ~/.clawlink/venv && source ~/.clawlink/venv/bin/activate"
    exit 1
}

# Optional: mDNS support for zero-config LAN discovery
echo "[2/3] Installing mDNS support (optional — for LAN auto-discovery)..."
pip install --user zeroconf 2>/dev/null || echo "  (mDNS skipped — manual --relay URL required for LAN, HTTP still works)"

echo "[3/3] Setup complete!"
echo ""

if [ "$MODE" = "server" ] || [ "$MODE" = "both" ]; then
    echo "Start the relay server with:"
    echo "  python3 $SCRIPT_DIR/server.py --token YOUR_SHARED_SECRET"
    echo ""
    echo "Options:"
    echo "  --port 9077           Custom port (default: 9077)"
    echo "  --host 127.0.0.1      Bind to localhost only (default; safest)"
    echo "  --host 0.0.0.0        Bind to all interfaces (LAN access)"
    echo "  --token SECRET        Shared auth token (required for multi-machine)"
    echo "  --no-mdns             Disable LAN broadcast"
    echo ""
fi

if [ "$MODE" = "client" ] || [ "$MODE" = "both" ]; then
    echo "Connect an agent with:"
    echo "  python3 $SCRIPT_DIR/client.py --relay http://HOST:9077 --token YOUR_SHARED_SECRET register --name 'my-agent' --caps 'code,search'"
    echo ""
    echo "Or scan for relays on your LAN:"
    echo "  python3 $SCRIPT_DIR/client.py scan"
    echo ""
fi

if [ "$MODE" = "both" ]; then
    echo "Quick start (run in separate terminals):"
    echo "  Terminal 1:  python3 $SCRIPT_DIR/server.py --token mysecret"
    echo "  Terminal 2:  python3 $SCRIPT_DIR/client.py --token mysecret register --name agent-a --caps 'code,search'"
    echo "  Terminal 3:  python3 $SCRIPT_DIR/client.py --token mysecret register --name agent-b --caps 'write,review'"
    echo ""
    echo "Then delegate a task:"
    echo "  python3 $SCRIPT_DIR/client.py --token mysecret delegate --to <agent-b-id> --task 'Review my code'"
fi


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)