Secure WebSocket proxy that connects ClawReach mobile/web clients to OpenClaw Gateway over Tailscale
ClawReach Bridge is the missing link between the ClawReach multi-platform chat client and an OpenClaw Gateway instance running on your network. It acts as a trusted intermediary — accepting WebSocket connections from ClawReach apps over Tailscale's encrypted mesh and forwarding them to the Gateway with the correct Origin headers, so Gateway accepts the connection without any modifications.
OpenClaw Gateway enforces strict WebSocket origin checks (GitHub issues #9358, PR #10695, CVE-2026-25253) to prevent cross-site WebSocket hijacking. ClawReach clients connecting from mobile devices or remote machines present private/Tailscale IPs that fail these checks. Rather than weakening Gateway's security model, the bridge solves this at the network layer:
- Tailscale-only access: Only accepts connections from Tailscale network (100.64.0.0/10) — no public internet exposure
- Origin header injection: Rewrites headers so Gateway sees a trusted localhost connection
- Zero Gateway changes: Works with a completely unmodified OpenClaw installation
- End-to-end encryption: Tailscale provides the security boundary between client and bridge
- Graceful shutdown: Two-phase drain sends WebSocket close frames before terminating
- Web Admin UI: Built-in browser dashboard for monitoring and control
# 1. Install
curl -fsSL https://raw.githubusercontent.com/cortexuvula/clawreachbridge/master/scripts/install.sh | bash
# 2. Setup (auto-detects Tailscale IP, writes config, starts service)
sudo clawreachbridge setup
# 3. Verify
curl http://127.0.0.1:8081/health
# 4. Open admin dashboard
open http://127.0.0.1:8081/ui/The setup wizard will detect your Tailscale IP, prompt for Gateway URL and ports, write the config file, and optionally start the systemd service.
For manual installation, download the binary from releases and see configs/config.example.yaml for configuration reference.
ClawReach Client (iOS / Android / Web)
↓ (Tailscale, encrypted)
ClawReach Bridge (this project)
↓ (localhost, proper Origin headers)
OpenClaw Gateway (unmodified)
The bridge binds to your machine's Tailscale IP so only devices on your tailnet can reach it. Connections to Gateway go over localhost, where Origin headers are trusted.
Designed for long-lived WebSocket connections with clean lifecycle management:
- Graceful close frames: Clients receive proper WebSocket close frames with status codes and reasons instead of raw TCP resets, letting client-side reconnection logic distinguish between intentional shutdowns and network failures.
- Two-phase shutdown: On SIGTERM/SIGINT, the bridge stops accepting new connections, sends
StatusGoingAwayclose frames to all active clients, waits for drain (up todrain_timeout), then force-closes any remaining. - Keepalive pings: Periodic WebSocket pings detect dead connections. Failed pings send a close frame with "keepalive timeout" before teardown.
- Circuit breaker: Fast-fails new connections when the gateway is down instead of blocking on dial timeout. Configurable failure/success thresholds and recovery timeout.
- Adaptive write timeout: Large messages (file attachments) get scaled timeouts based on
min_write_bytes_per_secinstead of a fixed deadline, preventing legitimate transfers from being killed. - Stream drain: When a client disconnects mid-stream, the bridge continues reading from the gateway to capture the final or aborted AI response for cross-device sync.
- Abort-aware:
chat.abort(state"aborted") is recognized as terminal throughout — stored in sync history, captured by the observer, and handled by stream drain. Partial text is preserved with astopReasonfield. - Tunable timeouts:
write_timeout,ping_interval,pong_timeout, anddial_timeoutare all independently configurable.
| Scenario | Close Code | Reason |
|---|---|---|
| Gateway unreachable | 1014 (Bad Gateway) | gateway unreachable |
| Circuit breaker open | 1014 (Bad Gateway) | gateway circuit breaker open |
| Keepalive failure | 1001 (Going Away) | keepalive timeout |
| Server shutdown | 1001 (Going Away) | server shutting down |
- Tailscale-only: Validates client IPs against Tailscale CGNAT ranges (IPv4
100.64.0.0/10, IPv6fd7a:115c:a1e0::/48) - Auth tokens: Header-based (
Authorization: Bearer) primary, query param (?token=) fallback - Admin token: Separate token for web admin API mutation endpoints
- Per-IP rate limiting: Token bucket rate limiter for connection attempts and message throughput
- Connection limits: Global and per-IP caps with atomic check-and-increment (no TOCTOU race)
- Public paths: Configurable path prefixes that bypass auth (e.g., A2UI static assets served to WebViews)
- HTTP body limits: Non-WebSocket requests are capped at
max_http_body_size(default 10MB) to prevent memory exhaustion - File receive limits: Per-file and cumulative session budgets for received attachments, with automatic cleanup of old inbox files (
inbox_max_age) - Bounded inspector buffering: Messages exceeding
max_inspected_message_sizebypass inspectors and stream directly, with message-type classification (file/chat/other) logged at Warn and tracked via metrics
Every connection gets a unique 8-character hex trace ID (X-Trace-Id header), propagated through all log lines for that connection. Makes correlating logs across proxy, inspector, and cleanup phases trivial.
clawreachbridge doctor runs pre-flight diagnostics:
- Config file validation
- Gateway reachability check (HTTP + WebSocket)
- Tailscale status verification
- systemd service status
- Port conflict detection
Supports --json output for CI/monitoring integration.
Captures chat messages flowing through the bridge and enables cross-device history:
- In-memory ring buffer with configurable depth per session (
max_history, default 200) - JSONL disk persistence: Survives bridge restarts. Automatic compaction when append count exceeds 2x buffer size.
- Session eviction: TTL-based expiry (
session_ttl, default 24h) and max sessions cap (max_sessions, default 100) prevent unbounded memory growth. Background cleanup goroutine runs automatically. - User echo broadcast: When a user sends a message from one device, sibling clients on the same session receive a synthetic
chatevent echo immediately. - History interception:
sessions.historyrequests are answered from the bridge's store without hitting the gateway. - Attachment support: File attachments (images, audio, documents) are stored alongside text content.
- Aborted message capture: AI responses stopped via
chat.abortare stored with partial text andstopReasonmetadata, so clients can render "(stopped)" indicators.
bridge:
sync:
enabled: true
max_history: 200
data_dir: "/var/lib/clawreachbridge/sync"
max_sessions: 100 # evict oldest when exceeded
session_ttl: "24h" # evict sessions idle longer than thisWhen OpenClaw generates images, they are saved to the gateway's outbound media directory but not included in WebSocket chat messages. The bridge detects these images and injects them inline:
- Tracks chat run IDs from streaming delta messages
- On final chat message, scans the media directory for images created during that run
- Deduplicates across scans to prevent re-injection
- Base64-encodes matching images and appends as
{ type: "image" }content items
Setup: The setup wizard prompts for the media directory path. When specified, it automatically adds the bridge.media section and creates a systemd override for file access.
Manual configuration:
bridge:
media:
enabled: true
directory: "/home/youruser/.openclaw/media/outbound"
max_file_size: 10485760 # 10MB max per image
max_age: "60s" # Only inject images created within this window
extensions: [".png", ".jpg", ".jpeg", ".webp", ".gif"]
inject_paths: [] # Empty = all paths; set prefixes to restrict
allowed_dirs: [] # Restrict MEDIA: path resolution (default-deny when set)
max_receive_file_size: 10485760 # 10MB per received file attachment
inbox_max_total_size: 104857600 # 100MB cumulative session budget
inbox_max_age: "24h" # Auto-cleanup received files older than thisShadows canvas.present, canvas.hide, and canvas.a2ui.pushJSONL messages from the gateway and replays them to reconnecting clients, so WebView-based surfaces survive connection drops.
bridge:
canvas:
state_tracking: true
jsonl_buffer_size: 5 # Recent JSONL payloads to retain
max_age: "5m" # Discard stale canvas state
a2ui_url: "" # Optional: inject A2UI URL into canvas.presentMaintains a long-lived WebSocket connection to the gateway to capture messages when no client is connected (e.g., overnight agent runs). Messages are stored via the sync layer and available to clients on reconnect.
- Automatic reconnect: Reconnects with exponential backoff on disconnection
- File attachment download: Captures file attachments from messages and stores them locally with retry logic and budget enforcement
- Active client suppression: Pauses observer capture when real clients are connected to avoid duplicates
- Configurable session key: Targets a specific gateway session (default:
agent:main:main)
observer:
enabled: true
session_key: "agent:main:main"
file_storage:
enabled: true
directory: "/var/lib/clawreachbridge/observer-files"
max_file_size: 52428800 # 50MB per file
max_total_size: 5368709120 # 5GB total
max_age: "168h" # 7 daysThe proxy pipeline includes specific protections against silent message loss:
- Write failure tracking: All 10 write-failure paths in the forwarding loop log at Warn with structured metadata (
write_path,payload_size,connection_uptime) and increment thewrite_failures_totalmetric. No message is silently dropped. - Inspector bypass visibility: When a message exceeds
max_inspected_message_sizeand bypasses inspectors, the first 4KB is classified asfile/chat/otherand tracked via theinspection_bypassed_totalmetric. - Atomic budget reservation: Observer file storage uses optimistic reservation to prevent concurrent downloads from exceeding the total size budget (TOCTOU race fix).
- Inbox cleanup: Received file attachments are automatically cleaned up after
inbox_max_age, with budget recalculation.
Optional metrics served on the health listener (/metrics):
clawreachbridge_connections_total— total WebSocket connections acceptedclawreachbridge_active_connections— current open connectionsclawreachbridge_messages_total{direction}— messages proxied (upstream/downstream)clawreachbridge_errors_total{type}— errors by type (accept_failure, dial_failure, tailscale_rejected, auth_rejected, rate_limit_exceeded, connection_limit, circuit_breaker_open, subprotocol_rejected)clawreachbridge_reactions_total{emoji}— reaction eventsclawreachbridge_canvas_events_total{action}— canvas state eventsclawreachbridge_canvas_replays_total— canvas replays on reconnectclawreachbridge_circuit_breaker_state— circuit breaker state (0=closed, 1=open, 2=half-open)clawreachbridge_sync_messages_total{role}— sync messages storedclawreachbridge_sync_sessions_active— active sync sessionsclawreachbridge_sync_broadcasts_total— sync broadcast operationsclawreachbridge_sync_jsonl_*— JSONL persistence metrics (errors, compactions, corrupt lines)clawreachbridge_messages_inspection_bypassed_total{direction,message_hint}— messages that bypassed inspectors due to sizeclawreachbridge_write_failures_total{direction,write_path}— message write failures by path (bypass/inspected/streaming)clawreachbridge_fcm_push_total{status}— FCM push notification operationsclawreachbridge_fcm_tokens_active— registered FCM device tokens
monitoring:
metrics_enabled: true
metrics_endpoint: "/metrics"Built-in web dashboard — no extra install, no separate process. Starts automatically whenever the bridge is running.
To use it: open http://127.0.0.1:8081/ui/ in any browser on the machine running the bridge.
The UI is embedded in the single binary via go:embed and served on the health listener. It provides five tabs:
- Dashboard: Real-time status — uptime, active/total connections, messages proxied, gateway reachability, memory usage, goroutine count, build info. Auto-refreshes every 3 seconds.
- Connections: Per-IP connection breakdown sorted by count. Auto-refreshes every 5 seconds.
- Config: View and edit reloadable settings (log level, connection limits, rate limits, message size). Changes are in-memory only. Read-only settings shown separately.
- Logs: Live log viewer with level filtering and incremental auto-refresh. Up to 1000 recent entries from a ring buffer.
- Controls: Reload config from disk (equivalent to
kill -HUP) or restart service via systemd.
Security: The admin UI is served on the health listener (127.0.0.1:8081) which is localhost-only. Mutation endpoints require Content-Type: application/json. Auth token values are never exposed via the config API. An optional admin_token protects mutation endpoints.
The web UI is powered by a JSON API available at /api/v1/ on the health listener:
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/status |
Dashboard data (uptime, connections, memory, version) |
| GET | /api/v1/connections |
Per-IP active connection breakdown |
| GET | /api/v1/config |
Current config (reloadable + read-only, auth token masked) |
| PUT | /api/v1/config |
Update reloadable config fields (in-memory only) |
| GET | /api/v1/logs?limit=100&level=info&since=<RFC3339> |
Recent log entries from ring buffer |
| POST | /api/v1/reload |
Reload config from disk |
| POST | /api/v1/restart |
Restart service via systemd |
Key settings in /etc/clawreachbridge/config.yaml (see configs/config.example.yaml for all options):
| Setting | Default | Description |
|---|---|---|
bridge.listen_address |
100.64.0.1:8080 |
Tailscale IP + port to bind |
bridge.gateway_url |
http://localhost:18800 |
OpenClaw Gateway upstream |
bridge.drain_timeout |
30s |
Max wait for connections to close on shutdown |
bridge.write_timeout |
30s |
Base deadline for writing a single message |
bridge.min_write_bytes_per_sec |
0 |
Floor for adaptive write timeout (0 = fixed timeout) |
bridge.ping_interval |
30s |
WebSocket ping frequency for dead peer detection |
bridge.pong_timeout |
10s |
Max wait for pong response |
bridge.max_message_size |
262144 |
Max WebSocket message size (256KB) |
bridge.max_http_body_size |
10485760 |
Max HTTP body for non-WS requests (10MB) |
bridge.media.enabled |
false |
Enable image injection from media directory |
bridge.sync.enabled |
false |
Enable cross-device message sync |
bridge.sync.max_sessions |
100 |
Max sessions to retain (0 = unlimited) |
bridge.sync.session_ttl |
24h |
Evict sessions idle longer than this |
bridge.canvas.state_tracking |
false |
Shadow canvas state for reconnect replay |
bridge.circuit_breaker.enabled |
false |
Fast-fail when gateway is down |
observer.enabled |
false |
Enable persistent observer connection |
security.tailscale_only |
true |
Require Tailscale IP |
security.auth_token |
"" |
Optional auth token for proxy connections |
security.admin_token |
"" |
Optional auth token for admin API mutations |
security.max_connections |
1000 |
Global connection limit |
security.max_connections_per_ip |
10 |
Per-IP connection limit |
monitoring.metrics_enabled |
false |
Enable Prometheus metrics |
All settings support environment variable overrides with the CLAWREACH_ prefix (e.g. CLAWREACH_BRIDGE_WRITE_TIMEOUT=60s).
- Implementation Plan - Comprehensive design document
- Configuration Reference - Example config with all options
- OpenClaw: Gateway must be running on the same machine (typically on localhost:18800)
- Tailscale: Must be installed and running on both the bridge machine and client devices
- ClawReach: The mobile/web client that connects through the bridge
- OS: Linux (primary), macOS (supported)
MIT (or Apache 2.0 - TBD)
See IMPLEMENTATION_PLAN.md § 16 for guidelines.
Created by Andre Hugo (@cortexuvula) to connect ClawReach clients to OpenClaw Gateway without modifying either.
Built with Claude Code Opus 4.6 from Fred's implementation plan.