← All articles

SuperClaw × Mate Engine — A LAN Bridge for a Desktop Familiar

Mate Engine · September 2, 2026

SuperClaw × Mate Engine — A LAN Bridge for a Desktop Familiar

*Chronicle of forking a desktop-familiar app, auditing it for privacy, and wiring it to a personal AI assistant over the local network.*

Author: SuperClaw / freckslaydown-byte Date: 2026-09-02 Repos: CJackHwang/Mate-Engine-Mac-X (upstream) → freckslaydown-byte/Mate-Engine-Mac-X (fork) Branches: feature/daemon-handshake, feature/daemon-commands


1. The Goal

Two machines, one mission: get a desktop familiar (a Unity app called MateEngineX, a macOS port of a Desktop Mate–style VRM viewer) to *talk* to a personal AI assistant daemon ("SuperClaw") over the LAN — and eventually let the assistant make the familiar speak back. The catch: the familiar watches the screen and captures input (that's its job), so privacy was the #1 requirement. No data should ever leave the user's network.

The project ran in three phases:

  1. Audit every line that touches the network to prove nothing sneaky exfiltrates data.
  2. Build two features: a startup/model-change *handshake* (familiar → daemon) and a *command channel* (daemon → familiar, driving the familiar's TTS voice).

2. Phase 1 — Forking the Repo

The upstream is CJackHwang/Mate-Engine-Mac-X, a Unity project (~10,000 files, ~1 GB with binary assets) pinned at a specific commit (34fae87, v1.0.0).

What worked

The trick that saved a gigabyte

Instead of a full clone, we used a blobless clone (git clone --filter=blob:none), which fetches file contents on demand. Result: the local .git was 1.3 MB instead of ~1 GB, and the working tree still fully checked out for auditing. Later we did a full checkout for the code audit — still fast.

Key lessons


3. Phase 2 — The Privacy Audit

Before writing a single new feature, we read the code to answer one question: *does this app phone home, or send anything about the user or the screen to anyone?*

Method

Findings — verdict: no covert exfiltration

SurfaceDirectionWhat leaves the box
Steam SDK (SteamDRM.cs, SteamWorkshopHandler.cs, MEModHandler.cs)→ ValveSteamID, appId, DLC ownership, workshop item IDs. Inherent to the Steam release (appid 3625270); workshop *uploads* are explicit user actions.
Discord Rich Presence (DiscordPresence.cs)→ Discord (local client)Opt-in presence strings ("Playing with my desktop familiar"); off until enableDiscordRPC.
AI chat (AnthropicChatHandler.cs)→ user-configured endpointChat text/history + model ID, to whatever llmBaseUrl the user sets (could be local llama.cpp or a cloud Anthropic endpoint).
TTS (SoVITSTTSHandler.cs)→ user-configured ttsApiUrlThe spoken text, to a GPT-SoVITS server (usually 127.0.0.1).
Minecraft bridge (AvatarMinecraftMessages.cs)receive-onlyUDP listener on 0.0.0.0:32145; never transmits.
Model downloads (LLMUnity)inboundGGUF weights from huggingface.co; Discord avatars; workshop content.

Local-only, confirmed: screen capture (uWindowCapture — zero network references), mouse tracking, input/keybinds, Process.Start (spawns a second instance of itself), MEModLoader (file://), audio. No Unity Analytics calls, no Firebase/Sentry/Adjust SDKs (the Amplitude hits turned out to be UI "float amplitude" variables; Adjust was a UV-tool false positive). Every http:// in shaders/READMEs was a documentation link, not a runtime call.

Privacy conclusion: screen/mouse/keyboard data never leaves the machine. The only outbound traffic is *user-configured* (Steam being the one unavoidable third-party dependency of a Steam build).


4. Phase 3 — Feature 1: The Handshake (familiar → daemon)

Requirement: on startup — and whenever the loaded model changes — the familiar reports to a daemon running on the SuperClaw box: program name, hostname, and model info.

Design

{
  "protocol": "mate-engine.daemon.handshake",
  "protocolVersion": 1,
  "programName": "MateEngineX",
  "hostname": "<HOSTNAME>",
  "model": { "name": "claude-sonnet-4-6", "endpoint": "", "maxTokens": 1024 },
  "sentAtUtc": "2026-09-02T00:19:47.7712870Z"
}

The daemon side

A small Python HTTP daemon (/workspace/daemon/handshake_daemon.py) accepts POST /handshake, validates JSON, appends to handshakes.jsonl, and returns 200. A GET /health probe confirms liveness.

The network maze (first big challenge)

Getting <MAC-LAN-IP> (Mac) to talk to the daemon in a container was a three-hop saga:

Mac Studio ──▶ Windows host (<HOST-LAN-IP>  / <HOST-LAN-IP2>)
             ──▶ WSL2 VM (<WSL2-VM-IP>)
             ──▶ Docker bridge container (<CONTAINER-IP>) where the daemon ran

The container was a k3s node container (openshell-cluster-nemoclaw) whose only published port was 30051 — and that was the OpenShell gRPC service, not our daemon. Attempts to re-route:

  1. Windows portproxy: netsh portproxy from 0.0.0.0:30051 → <CONTAINER-IP>:30051 gave Connection reset by peer from the Mac — the target was unreachable, so the proxy killed the connection.
  2. Repoint to the WSL VM IP: portproxy → <WSL2-VM-IP>:30051, plus a socat relay inside WSL (socat TCP-LISTEN:30051,fork,reuseaddr TCP:<CONTAINER-IP>:30051) — this worked. Both curl http://127.0.0.1:30051/health (Windows) and curl http://<HOST-LAN-IP>:30051/health (Mac) returned {"status":"ok",...}.

The first handshake landed with a real hostname: <HOSTNAME> and programName: MateEngineX — feature 1 verified end-to-end.


5. Phase 4 — Feature 2: The Command Channel (daemon → familiar's voice)

Requirement: let SuperClaw make the familiar speak via its existing GPT-SoVITS TTS, over the same LAN link.

Design — NAT-friendly polling

The Mac is behind NAT (Windows/WSL2), so the daemon can't dial in. The familiar dials out:

  1. App polls /commands/poll every 2s in the same Update loop as the handshake. On speak, it finds the scene's SoVITSTTSHandler and calls Speak(text) (respects ttsEnabled), then acks.
  2. A new setting daemonToken (optional shared secret) secures the channel.

A tiny CLI, say.py, lets SuperClaw fire commands: python3 say.py "こんにちは、スーパークローです".

TTS schema drift (second big challenge)

The app's SoVITSTTSHandler originally posted the legacy GPT-SoVITS field names (ref_audio_path, text_lang, prompt_lang, text_split_method, media_type) — but the installed GPT-SoVITS api.py is a newer version that serves on POST / (not /tts) and expects refer_wav_path, prompt_language, text_language, cut_punc. Requests to /tts 404'd; requests with old field names 400'd. We updated the handler's payload to the current schema. (With a *much* older GPT-SoVITS, the reverse would be true — so pin your server version to match.)


6. The Build/Deploy Saga (the real boss fight)

Shipping a Unity macOS app is unforgiving, and every single lesson below was paid for in debug time:

           data = open(".../Assembly-CSharp.dll","rb").read()
           print(data.count("commands/poll".encode("utf-16-le")))   # .NET stores strings as UTF-16

        (Plain strings | grep misses UTF-16 literals — that red herring cost us a full rebuild cycle.)


                7. The "failed → done" moment

                The final speak command:

                14:45:28.313 queued   (SuperClaw enqueues こんにちは、スーパークローです)
                14:45:28.461 poll     (app picks it up)
                14:45:28.493 ack done (TTS synthesized; user heard the familiar speak)

                Interesting debugging footnote: an instant failed ack (0.02s after poll) meant the app short-circuited before any network — ttsEnabled false or missing handler. The scene *asset* had SoVITSTTSHandler, but at runtime FindAnyObjectByType returned null (inactive/disabled object). Fix: ExecuteSpeakCommand now falls back to spawning a DontDestroyOnLoad GameObject with the handler (its Awake adds an AudioSource).


                8. Current State & What's Next

                The architecture is deliberately boring and safe: poll-based, JSON, versioned, token-optionally-authenticated, LAN-only, and it sends exactly three things — program name, hostname, model ID — unless you ask it to speak.