Real-time Kanban board and encrypted message feed for tracking oh-my-opencode agent work. Designed to run on a headless Mac Mini, accessed securely over Tailscale from your phone or laptop.
- Features
- Architecture Overview
- Prerequisites
- Step 1 — Mac Mini Setup
- Step 2 — Install Tailscale on Mac Mini
- Step 3 — Install & Configure OpenClaw
- Step 4 — Deploy the Dashboard
- Step 5 — Expose the Dashboard via Tailscale Serve
- Step 6 — Connect Clients
- Step 7 — oh-my-opencode Hook
- Step 8 — Verify Everything Works
- API Reference
- Environment Variables
- Security Hardening Checklist
- Tailscale ACL Policy (Optional)
- Troubleshooting
- Tech Stack
- Mobile App
- Kanban Board — Drag-and-drop task management with status columns (Pending, In Progress, Completed)
- Encrypted Messages — All notifications encrypted at rest using NaCl secretbox
- Real-time Updates — Automatic polling every 3 seconds (SSE planned)
- Dark Mode — Full dark mode support on web and mobile
- Cross-platform — Next.js web app + React Native mobile app
- oh-my-opencode Integration — Hook sends agent updates to the dashboard
- Zero-Trust Access — Tailscale Serve provides auto-HTTPS, identity headers, and tailnet-only access
┌─────────────────────────────────────────────────────────┐
│ Mac Mini (headless) │
│ │
│ ┌──────────────┐ ┌──────────────────────────────┐ │
│ │ OpenClaw │ │ Next.js Dashboard │ │
│ │ Gateway │ │ 127.0.0.1:3000 │ │
│ │ 127.0.0.1: │ │ │ │
│ │ 18789 │ │ SQLite + NaCl encryption │ │
│ └──────┬───────┘ └──────────────┬───────────────┘ │
│ │ │ │
│ │ oh-my-opencode hook │ │
│ │ POSTs events ──────────►│ │
│ │ │ │
│ ┌──────┴──────────────────────────┴───────────────┐ │
│ │ Tailscale Serve │ │
│ │ / → 127.0.0.1:18789 (OpenClaw) │ │
│ │ /opencode → 127.0.0.1:3000 (Dashboard) │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
▲ ▲
│ WireGuard tunnel │ WireGuard tunnel
│ (encrypted) │ (encrypted)
┌────┴─────┐ ┌──────┴──────┐
│ iPhone │ │ Laptop │
│ Tailscale │ Tailscale │
│ app │ │ + browser │
└──────────┘ └─────────────┘
Key design principle: Both services bind to 127.0.0.1 only. Tailscale Serve proxies HTTPS traffic from your tailnet to localhost — no ports are exposed on the LAN or internet.
| Requirement | Minimum | Notes |
|---|---|---|
| macOS | 13 Ventura+ | Mac Mini target OS |
| Node.js | 20+ | LTS recommended |
| Bun | 1.0+ | Package manager & runtime |
| Git | 2.39+ | Ships with Xcode CLI tools |
| Tailscale account | Free tier | tailscale.com/start |
| Anthropic API key | — | For OpenClaw / Claude |
If starting from a fresh Mac Mini:
# Install Xcode command-line tools (includes git, clang, etc.)
xcode-select --install
# Install Homebrew
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# Install Node.js and Bun
brew install node
brew install oven-sh/bun/bun
# Verify
node --version # Should be 20+
bun --version # Should be 1.0+
git --version # Should be 2.39+Default recommendation: run install + onboarding as your normal logged-in macOS GUI user.
OpenClaw onboarding on macOS depends on per-user TCC permissions (Accessibility, Screen Recording, Automation, Notifications, etc.). If you switch to a separate non-admin user too early, onboarding commonly fails or misses required permissions.
Use a dedicated opagent user only for hardened runtime separation after OpenClaw is already installed/onboarded and working:
# Create user (requires admin)
sudo dscl . -create /Users/opagent
sudo dscl . -create /Users/opagent UserShell /bin/zsh
sudo dscl . -create /Users/opagent RealName "OpenCode Agent"
sudo dscl . -create /Users/opagent UniqueID 550
sudo dscl . -create /Users/opagent PrimaryGroupID 20
sudo dscl . -create /Users/opagent NFSHomeDirectory /Users/opagent
sudo mkdir -p /Users/opagent
sudo chown opagent:staff /Users/opagent
# Set password
sudo dscl . -passwd /Users/opagent <password>
# Optional: switch after OpenClaw onboarding is complete
su - opagentIf you use opagent, keep OpenClaw in the original onboarded user context and run the dashboard service under opagent.
Download the standalone macOS app (recommended over App Store for headless use):
brew install --cask tailscaleOr download directly from tailscale.com/download/mac.
# Start Tailscale and authenticate
tailscale upThis opens a browser to sign in with your identity provider (Google, GitHub, Microsoft, etc.). On a headless Mac Mini without a display, use --auth-key:
# Generate an auth key at: https://login.tailscale.com/admin/settings/keys
tailscale up --auth-key=tskey-auth-XXXXXtailscale set --sshThis lets you SSH into the Mac Mini from any device in your tailnet without SSH keys — Tailscale handles authentication via your identity provider.
tailscale status
# Should show your Mac Mini's Tailscale IP (100.x.y.z)
tailscale ip -4
# Prints just the IPv4 addressUse one of these methods:
# Recommended (requires jq)
tailscale status --json | jq -r '.MagicDNSSuffix'
# Example output: cat-crocodile.ts.net
# Without jq: print JSON and look for MagicDNSSuffix
tailscale status --jsonFor URLs like https://mac-mini.<your-tailnet>.ts.net, your <your-tailnet> is the suffix without .ts.net.
Example: cat-crocodile.ts.net -> <your-tailnet> is cat-crocodile.
You can also find it in the Tailscale admin console DNS page:
https://login.tailscale.com/admin/dns
MagicDNS is enabled by default on tailnets created after October 2022. To verify:
- Go to login.tailscale.com/admin/dns
- Confirm MagicDNS is toggled ON
- Under HTTPS Certificates, click Enable HTTPS
Note: Enabling HTTPS publishes your machine names on Let's Encrypt's Certificate Transparency logs. This reveals the name
mac-mini.<tailnet>.ts.netpublicly, but the machine itself remains inaccessible outside your tailnet.
Run this step as the same logged-in macOS user that will own OpenClaw permissions and onboarding.
Do not switch to opagent before this step.
# Option A: One-liner (recommended)
curl -fsSL https://openclaw.ai/install.sh | bash
# Option B: npm global
npm install -g openclaw@latestopenclaw onboard --install-daemonThis will:
- Create
~/.openclaw/openclaw.json(config file, JSON5 format) - Generate a gateway auth token
- Install a launchd service (
ai.openclaw.gateway) so the gateway runs at boot - Prompt for your Anthropic API key
Edit ~/.openclaw/openclaw.json:
{
gateway: {
port: 18789,
bind: "loopback", // CRITICAL: only 127.0.0.1
auth: {
mode: "token", // Require token for API access
// Token was auto-generated during onboarding.
// To regenerate: openclaw config set gateway.auth.token "$(openssl rand -hex 32)"
}
}
}openclaw gateway status # Check if running
openclaw gateway start # Start the daemon
openclaw gateway stop # Stop the daemon
openclaw gateway restart # Restart after config changes# Check the gateway is listening on loopback only
lsof -iTCP:18789 -sTCP:LISTEN
# Should show *:127.0.0.1:18789, NOT *:18789 or 0.0.0.0:18789~/.openclaw/
├── openclaw.json # Main config (JSON5)
├── .env # Env vars loaded by daemon
├── workspace/ # Agent workspace
├── agents/<agentId>/agent/
│ ├── auth-profiles.json # OAuth + API keys
│ └── auth.json # Runtime cache
└── credentials/ # Legacy OAuth imports
git clone https://github.com/Keeeeeeeks/opencode-dashboard.git
cd opencode-dashboardcp .env.example .env.localEdit .env.local — at minimum set DASHBOARD_API_KEY:
# Generate a secure key
openssl rand -hex 32Paste it as the value for DASHBOARD_API_KEY in .env.local:
HOST=127.0.0.1
PORT=3000
DASHBOARD_API_KEY=<your-generated-key>
ALLOWED_ORIGINS=http://127.0.0.1:3000,http://localhost:3000bun install
bun run build # Production build
bun run start # Starts on 127.0.0.1:3000Use launchd to keep the dashboard running across reboots:
cat > ~/Library/LaunchAgents/com.opencode-dashboard.plist << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.opencode-dashboard</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>WorkingDirectory</key>
<string>/Users/opagent/opencode-dashboard</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/bun</string>
<string>run</string>
<string>start</string>
</array>
<key>StandardOutPath</key>
<string>/tmp/opencode-dashboard.log</string>
<key>StandardErrorPath</key>
<string>/tmp/opencode-dashboard.err</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>
</dict>
</plist>
EOF
# Adjust WorkingDirectory if your clone is elsewhere
# Load the service
launchctl load ~/Library/LaunchAgents/com.opencode-dashboard.plist
# Verify
curl http://127.0.0.1:3000curl -s http://127.0.0.1:3000 | head -20
# Should return HTML
curl -s http://127.0.0.1:3000/api/sessions
# Should return JSON (empty array initially)This makes both the OpenClaw control UI and the dashboard accessible from your phone and laptop without opening any ports.
# OpenClaw control UI at the root
tailscale serve --bg --set-path / 18789
# OpenCode Dashboard at /opencode
tailscale serve --bg --set-path /opencode 3000This gives you:
| URL | Service |
|---|---|
https://<hostname>.<tailnet>.ts.net/ |
OpenClaw control UI |
https://<hostname>.<tailnet>.ts.net/opencode |
OpenCode Dashboard |
When serving the dashboard at a subpath, Next.js needs to know where to load CSS/JS from. Add to .env.local:
ASSET_PREFIX=/opencodeThen rebuild and restart:
bun run build && bun run startWithout this, the page HTML loads but styles and interactivity will be missing.
tailscale serve status
# Should show:
# https://<hostname>.<tailnet>.ts.net/
# |-- proxy http://127.0.0.1:18789
#
# https://<hostname>.<tailnet>.ts.net/opencode
# |-- proxy http://127.0.0.1:3000From another device in your tailnet:
https://<hostname>.<tailnet>.ts.net → OpenClaw
https://<hostname>.<tailnet>.ts.net/opencode → Dashboard
Every device that needs to access the dashboard must join your Tailscale network. The key rule: sign in with the same identity provider you used on the Mac Mini.
- Install Tailscale from the App Store
- Open the app → Sign in with the same account (Google, GitHub, etc.)
- Allow the VPN configuration when prompted
- Open Safari and visit:
- OpenClaw:
https://<hostname>.<tailnet>.ts.net - Dashboard:
https://<hostname>.<tailnet>.ts.net/opencode
- OpenClaw:
VPN On Demand (recommended): iOS Settings → VPN → Tailscale → Connect On Demand → ON. This keeps the tunnel alive so pages load instantly.
brew install --cask tailscale
tailscale up
open https://<hostname>.<tailnet>.ts.net/opencodecurl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
xdg-open https://<hostname>.<tailnet>.ts.net/opencode- Download from tailscale.com/download/windows
- Sign in with the same identity provider
- Open
https://<hostname>.<tailnet>.ts.net/opencode
On the Mac Mini:
tailscale statusOr check the admin console: login.tailscale.com/admin/machines — your new device should appear in the list.
Once Tailscale is connected on the device, the app reaches the API directly:
cd mobile
bun install
export EXPO_PUBLIC_API_URL=https://<hostname>.<tailnet>.ts.net
bun run ios # or bun run androidThe hook sends agent events (task progress, session updates, todos) to the dashboard in real time.
# On the Mac Mini (where oh-my-opencode runs)
cp opencode-hook/dashboard-hook.ts ~/.opencode/hooks/# Add to your shell profile (~/.zshrc or ~/.bashrc)
export DASHBOARD_URL=http://127.0.0.1:3000
export DASHBOARD_API_KEY=<same-key-from-env.local>Since OpenClaw and the dashboard both run on the Mac Mini, the hook communicates over loopback — no network exposure.
# Send a test event
curl -X POST http://127.0.0.1:3000/api/events \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $DASHBOARD_API_KEY" \
-d '{"type": "test", "data": {"message": "Hook test"}}'Run through this checklist after setup:
# 1. OpenClaw gateway is running on loopback
lsof -iTCP:18789 -sTCP:LISTEN
# ✓ Should show 127.0.0.1:18789
# 2. Dashboard is running on loopback
lsof -iTCP:3000 -sTCP:LISTEN
# ✓ Should show 127.0.0.1:3000
# 3. Tailscale Serve is proxying
tailscale serve status
# ✓ Should show https://mac-mini.<tailnet>.ts.net -> http://127.0.0.1:3000
# 4. No ports exposed on LAN
# From another device on your local network (NOT tailnet):
curl http://<mac-mini-lan-ip>:3000
# ✓ Should fail / connection refused
# 5. Dashboard accessible via Tailscale
# From a device in your tailnet:
curl https://mac-mini.<your-tailnet>.ts.net
# ✓ Should return dashboard HTML
# 6. API auth is working
curl -s https://mac-mini.<your-tailnet>.ts.net/api/events \
-X POST -H "Content-Type: application/json" \
-d '{"type":"test"}'
# ✓ Should return 401 (no auth header)
curl -s https://mac-mini.<your-tailnet>.ts.net/api/events \
-X POST -H "Content-Type: application/json" \
-H "Authorization: Bearer $DASHBOARD_API_KEY" \
-d '{"type":"test","data":{}}'
# ✓ Should return 200Schedule OpenClaw to check the dashboard board on a recurring basis.
openclaw cron add \
--name "Check dashboard board" \
--every 4h \
--message "Check the OpenCode Dashboard at http://127.0.0.1:3000/api/todos for any stale in_progress tasks or high-priority pending items. Summarize what you find." \
--announce# Daily standup summary at 9am
openclaw cron add \
--name "Daily board summary" \
--cron "0 9 * * *" \
--tz "America/Los_Angeles" \
--message "Summarize yesterday's completed tasks and today's pending tasks from http://127.0.0.1:3000/api/todos. Be concise." \
--announce
# One-shot reminder
openclaw cron add \
--name "Reminder: review PRs" \
--at "+2h" \
--message "Reminder: review open PRs on opencode-dashboard." \
--announce \
--delete-after-runopenclaw cron list # List all jobs
openclaw cron run <id> # Test-run a job now
openclaw cron disable <id> # Pause a job
openclaw cron enable <id> # Resume a job
openclaw cron rm <id> # Delete a job| Endpoint | Method | Auth | Description |
|---|---|---|---|
/api/events |
POST | Bearer | Receive events from oh-my-opencode hook |
/api/todos |
GET | Bearer | Get all todos (query: session_id, status, since) |
/api/todos |
POST | Bearer | Create or update a single todo |
/api/todos |
PUT | Bearer | Batch create/update todos ({ todos: [...] }) |
/api/messages |
GET | Bearer | Get all messages (query: unread_only, since) |
/api/messages |
POST | Bearer | Mark messages as read |
/api/sessions |
GET | Bearer | List sessions |
/api/sessions |
POST | Bearer | Create a session |
| Variable | Required | Default | Description |
|---|---|---|---|
HOST |
No | 127.0.0.1 |
Bind address. Keep as loopback. |
PORT |
No | 3000 |
Server port |
DASHBOARD_API_KEY |
Yes | — | Shared secret for API auth (openssl rand -hex 32) |
ALLOWED_ORIGINS |
No | http://127.0.0.1:3000,http://localhost:3000 |
Comma-separated CORS allowlist |
RATE_LIMIT_WINDOW_MS |
No | 60000 |
Rate limit window in ms |
RATE_LIMIT_MAX_REQUESTS |
No | 60 |
Max requests per window per IP |
DASHBOARD_URL |
No | http://127.0.0.1:3000 |
Used by opencode-hook (agent side) |
DATA_DIR |
No | ~/.opencode-dashboard |
SQLite DB and encryption key location |
ASSET_PREFIX |
No | — | Set to subpath when behind a reverse proxy (e.g. /opencode) |
Status: Phase 0 complete. Most critical and high items addressed.
- API authentication — All endpoints validate
Authorization: Bearer <DASHBOARD_API_KEY>via timing-safe comparison. - Fix CORS —
ALLOWED_ORIGINSallowlist replaces wildcard*.Authorizationincluded in allowed headers. - Bind to loopback —
HOST=127.0.0.1in.env.local. Never setHOST=0.0.0.0.
- Rate limiting — POST endpoints protected with sliding-window rate limiter (
RATE_LIMIT_WINDOW_MS/RATE_LIMIT_MAX_REQUESTS). - Validate the hook contract — The hook in
opencode-hook/dashboard-hook.tswas written speculatively. Verify against the actual oh-my-opencode hook API. - Add auth to the hook — Hook sends
Authorization: Bearer ${DASHBOARD_API_KEY}on all requests.
- Replace polling with SSE — Reduce latency and server load.
- Encryption key management — Key file enforced to
chmod 600, data dir to0o700.DATA_DIRenv var supported. - Push notifications —
expo-notificationsis imported but not wired up. Add FCM or APNs. - Batch todo sync —
PUT /api/todosaccepts bulk upsert; hook batches all todos in one request with POST fallback.
- Migrate to PostgreSQL — Needed for multi-device or multi-agent setups.
- Offline caching — Mobile app has no offline support.
- Audit logging — Structured JSON logs for auth failures, rate limit hits, and auth successes.
If you want to restrict which tailnet devices can access the Mac Mini, edit your ACL policy at login.tailscale.com/admin/acls:
{
"groups": {
"group:dashboard-users": ["your-email@example.com"]
},
"acls": [
{
"action": "accept",
"src": ["group:dashboard-users"],
"dst": ["mac-mini:443"]
}
],
"ssh": [
{
"action": "accept",
"src": ["group:dashboard-users"],
"dst": ["mac-mini"],
"users": ["autogroup:nonroot"]
}
]
}This ensures only devices belonging to group:dashboard-users can reach the dashboard (port 443 via Tailscale Serve) or SSH into the Mac Mini.
# Is Next.js running?
curl http://127.0.0.1:3000
# If not: check launchd logs
cat /tmp/opencode-dashboard.log
cat /tmp/opencode-dashboard.err
# Is Tailscale Serve active?
tailscale serve status
# Is Tailscale connected?
tailscale status- Confirm Tailscale is connected on the client device (green icon, not grey)
- Confirm the Mac Mini shows as online in login.tailscale.com/admin/machines
- Try the Tailscale IP directly:
curl http://100.x.y.z:3000 - If that works but the hostname doesn't, MagicDNS may be off → enable at DNS settings
- Ensure Tailscale VPN is connected (check iOS Settings → VPN)
- Enable "VPN On Demand" to prevent disconnects
- The React Native app uses
EXPO_PUBLIC_API_URL— make sure it's set to thehttps://mac-mini.<tailnet>.ts.netURL
# Is Tailscale SSH enabled on Mac Mini?
tailscale status # Look for "ssh" in the features column
# Re-enable if needed
tailscale set --ssh
# Connect with verbose output
ssh -v your-username@mac-mini# Is the daemon running?
openclaw gateway status
# Check what port/interface it's listening on
lsof -iTCP:18789 -sTCP:LISTEN
# Check logs
cat /tmp/openclaw/openclaw-gateway.log
# Restart if needed
openclaw gateway restart| Layer | Technology |
|---|---|
| Web app | Next.js 16, TypeScript, Tailwind CSS, @dnd-kit, Zustand |
| Database | SQLite (better-sqlite3) |
| Encryption | tweetnacl (NaCl secretbox) |
| Mobile app | Expo, React Native, TypeScript, Zustand |
| Agent runtime | OpenClaw (gateway on port 18789) |
| Coding agents | oh-my-opencode (Sisyphus, Atlas, Hephaestus, etc.) |
| Secure access | Tailscale (WireGuard, MagicDNS, auto-HTTPS) |
cd mobile
bun install
bun run ios # or bun run androidSet EXPO_PUBLIC_API_URL to your Tailscale hostname for remote access, or http://localhost:3000 for local development.
| Item | Location | Notes |
|---|---|---|
| SQLite database | ~/.opencode-dashboard/data.db |
All todos, sessions, messages |
| Encryption key | ~/.opencode-dashboard/key |
NaCl key, chmod 600 |
| OpenClaw config | ~/.openclaw/openclaw.json |
Gateway settings |
| OpenClaw state | ~/.openclaw/agents/ |
Per-agent auth and state |
| Dashboard logs | /tmp/opencode-dashboard.log |
stdout from launchd |
| OpenClaw logs | /tmp/openclaw/openclaw-gateway.log |
Gateway daemon logs |