SuperClaw × Mate Engine — A LAN Bridge for a Desktop Familiar
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:
- Audit every line that touches the network to prove nothing sneaky exfiltrates data.
- 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
- A classic PAT with the
reposcope fixed it instantly:POST /repos/CJackHwang/Mate-Engine-Mac-X/forks→https://github.com/freckslaydown-byte/Mate-Engine-Mac-X(default branchmain).
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
- Forking gives you a *diverged* repo, not a mirror — but for our purposes (extend the code, no upstream PR), it was ideal.
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
- Extracted every unique hostname from the codebase and classified each.
- Read the app's own API scripts line by line (the vendored
Steamworks,DiscordRPC,LLMUnity, anduWindowCapturepackages were treated separately from the app's own code). - Checked
Process.Start,Application.OpenURL,streaming assets,ProjectSettings(Unity Analytics), and native plugin binaries.
Findings — verdict: no covert exfiltration
| Surface | Direction | What leaves the box |
|---|---|---|
Steam SDK (SteamDRM.cs, SteamWorkshopHandler.cs, MEModHandler.cs) | → Valve | SteamID, 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 endpoint | Chat 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 ttsApiUrl | The spoken text, to a GPT-SoVITS server (usually 127.0.0.1). |
Minecraft bridge (AvatarMinecraftMessages.cs) | receive-only | UDP listener on 0.0.0.0:32145; never transmits. |
| Model downloads (LLMUnity) | inbound | GGUF 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
- Two new settings (serialized to
settings.json, default safe):daemonEnabled(defaultfalse) anddaemonUrl(e.g.http://192.168.1.50:8080). - Payload — a versioned JSON handshake, POSTed to
{daemonUrl}/handshake:
{
"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"
}
- Security-minded by default: the app never sends screen/mouse/keyboard data — only the handshake fields.
settingsVersionbumped so existing users' settings migrate cleanly.
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:
- Windows portproxy:
netsh portproxyfrom0.0.0.0:30051→<CONTAINER-IP>:30051gaveConnection reset by peerfrom the Mac — the target was unreachable, so the proxy killed the connection. - Repoint to the WSL VM IP: portproxy →
<WSL2-VM-IP>:30051, plus asocatrelay inside WSL (socat TCP-LISTEN:30051,fork,reuseaddr TCP:<CONTAINER-IP>:30051) — this worked. Bothcurl http://127.0.0.1:30051/health(Windows) andcurl 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:
- App polls
/commands/pollevery 2s in the sameUpdateloop as the handshake. Onspeak, it finds the scene'sSoVITSTTSHandlerand callsSpeak(text)(respectsttsEnabled), then acks. - 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.)
KeepAlive=trueso it resurrects if it dies- Modern
launchctl bootstrap gui/$(id -u) …(the oldload/unloadfailed with5: Input/output error)
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
- ✅ Feature 1: handshake at startup + on model change (verified end-to-end)
- ✅ Feature 2: command channel drives the familiar's Japanese TTS (verified — user heard it)
- ✅ Daemon (
handshake_daemon.py) + CLI (say.py) running; logs at/workspace/daemon/{handshakes,commands}.jsonl - ✅ GPT-SoVITS auto-starts at login via LaunchAgent
- 🔜 Open ideas: state reporting (dance/idle), screen/window events, chat via the familiar, proximity triggers — all over the same poll protocol
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.