OpenClaw Studio Dashboard
Skill by ara.so — Hermes Skills collection.
OpenClaw Studio is a clean web dashboard for OpenClaw Gateway that provides a unified interface to connect gateways, manage agents, chat, handle approvals, and configure jobs. Built with TypeScript/Next.js, it runs a server-owned control plane architecture with SSE streaming for real-time runtime events.
What OpenClaw Studio Does
- Gateway Connection: Connect to local or remote OpenClaw Gateway instances via WebSocket
- Agent Management: Create, configure, and monitor AI agents with tool policies and sandbox settings
- Chat Interface: Stream conversations with PI agents including tool calls and thinking traces
- Approval Workflows: Manage exec approvals and runtime permissions
- Job Configuration: Set up and monitor cron jobs
- Runtime Streaming: Real-time event streaming over SSE with replay/history
Installation & Startup
Quick Start (Recommended)
# Run latest version with npx
npx -y openclaw-studio@latest
# Opens on http://localhost:3000
# Default gateway URL: ws://localhost:18789
From Source
git clone https://github.com/grp06/openclaw-studio.git
cd openclaw-studio
npm install
npm run dev
Setup Helper
# Configure gateway URL/token without opening UI
npm run studio:setup
Connection Architecture
OpenClaw Studio uses a two-path architecture:
- Browser → Studio: HTTP + SSE (
/api/runtime/*,/api/intents/*) - Studio → Gateway: Server-owned WebSocket to upstream OpenClaw
Critical concept: ws://localhost:18789 means "gateway on the Studio host", not "gateway on your browser's machine".
Configuration Patterns
Deployment Scenarios
A. Both Local (Same Computer)
# Start Studio
npx -y openclaw-studio@latest
cd openclaw-studio
npm run dev
# Open http://localhost:3000
# In Studio UI:
# - Upstream URL: ws://localhost:18789
# - Upstream Token: <your-gateway-token>
Get gateway token:
openclaw config get gateway.auth.token
B. Gateway in Cloud, Studio Local
Option 1: Tailscale Serve (Recommended)
On gateway host:
tailscale serve --yes --bg --https 443 http://127.0.0.1:18789
In Studio (local laptop):
- Upstream URL:
wss://<gateway-host>.ts.net - Upstream Token:
<gateway-token>
Option 2: SSH Tunnel
From laptop:
ssh -L 18789:127.0.0.1:18789 user@<gateway-host>
In Studio:
- Upstream URL:
ws://localhost:18789 - Upstream Token:
<gateway-token>
C. Both in Cloud (Always-On)
On Studio VPS:
# Start Studio
npx -y openclaw-studio@latest
cd openclaw-studio
npm install
npm run dev
# If OpenClaw is on same VPS:
# - Upstream URL: ws://localhost:18789
# - Upstream Token: <gateway-token>
# Expose Studio over Tailscale
tailscale serve --yes --bg --https 443 http://127.0.0.1:3000
# Access from anywhere: https://<studio-host>.ts.net
Environment Variables
# Gateway connection (optional, can set in UI)
NEXT_PUBLIC_GATEWAY_URL=ws://localhost:18789
# Studio access control (required for public binds)
STUDIO_ACCESS_TOKEN=your-secure-token-here
# OpenClaw state directory
OPENCLAW_STATE_DIR=~/.openclaw
# Bind host (default: 127.0.0.1)
HOST=0.0.0.0 # Public bind - requires STUDIO_ACCESS_TOKEN
Configuration Files
Studio settings are stored in ~/.openclaw/openclaw-studio/:
~/.openclaw/
├── openclaw.json # OpenClaw Gateway config
└── openclaw-studio/
├── settings.json # Gateway URL/token
└── runtime.db # Control-plane runtime DB
API & Key Commands
Studio Setup
# Development mode with auto-restart
npm run dev
# Production build
npm run build
npm run start
# Turbo dev mode
npm run dev:turbo
# Verify/repair native dependencies
npm run verify:native-runtime:repair
npm run verify:native-runtime:check
TypeScript API Patterns
Connecting to Gateway
// In Studio UI components
import { useGatewayConnection } from '@/hooks/useGatewayConnection';
export function DashboardPage() {
const { connected, connect, disconnect, status } = useGatewayConnection();
const handleConnect = async () => {
try {
await connect({
url: 'ws://localhost:18789',
token: process.env.GATEWAY_TOKEN
});
} catch (error) {
console.error('Connection failed:', error);
}
};
return (
<div>
<button disabled={connected}>
{connected ? 'Connected' : 'Connect'}
</button>
<span>Status: {status}</span>
</div>
);
}
Streaming Runtime Events
// Server-side SSE streaming
import { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
// Subscribe to runtime events
const subscription = runtimeStore.subscribe((event) => {
const data = `data: ${JSON.stringify(event)}\n\n`;
controller.enqueue(encoder.encode(data));
});
// Replay history
const history = await runtimeStore.getHistory();
for (const event of history) {
const data = `data: ${JSON.stringify(event)}\n\n`;
controller.enqueue(encoder.encode(data));
}
return () => subscription.unsubscribe();
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
}
});
}
Creating Agents
// Agent creation API
interface AgentConfig {
name: string;
systemPrompt: string;
toolPolicy: 'all' | 'allowlist' | 'denylist';
allowedTools?: string[];
sandboxEnabled: boolean;
execApprovalRequired: boolean;
}
export async function createAgent(config: AgentConfig) {
const response = await fetch('/api/intents/agent-create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agent_name: config.name,
system_prompt: config.systemPrompt,
tool_policy: config.toolPolicy,
allowed_tools: config.allowedTools,
sandbox_config: {
enabled: config.sandboxEnabled,
mounts: config.sandboxEnabled ? [
{ host: '/tmp', container: '/workspace', readonly: false }
] : []
},
exec_approval_required: config.execApprovalRequired
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to create agent');
}
return response.json();
}
Chat with Streaming
// Client-side chat with SSE
import { useEffect, useState } from 'react';
export function ChatInterface({ agentId }: { agentId: string }) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [thinking, setThinking] = useState<string[]>([]);
useEffect(() => {
// Connect to runtime stream
const eventSource = new EventSource('/api/runtime/stream');
eventSource.addEventListener('message', (event) => {
const data = JSON.parse(event.data);
switch (data.type) {
case 'tool_call':
setMessages(prev => [...prev, {
role: 'assistant',
content: `Using tool: ${data.tool_name}`,
metadata: { type: 'tool', ...data }
}]);
break;
case 'thinking':
setThinking(prev => [...prev, data.content]);
break;
case 'transcript':
setMessages(prev => [...prev, {
role: data.role,
content: data.content
}]);
setThinking([]);
break;
}
});
return () => eventSource.close();
}, [agentId]);
const sendMessage = async (content: string) => {
await fetch('/api/intents/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agent_id: agentId,
message: content
})
});
};
return (
<div>
{messages.map((msg, i) => (
<div key={i} className={`message ${msg.role}`}>
{msg.content}
</div>
))}
{thinking.length > 0 && (
<div className="thinking">
{thinking.map((t, i) => <p key={i}>{t}</p>)}
</div>
)}
<input => {
if (e.key === 'Enter') {
sendMessage(e.currentTarget.value);
e.currentTarget.value = '';
}
}} />
</div>
);
}
Real-World Examples
Example 1: Local Development Setup
#!/bin/bash
# setup-local-dev.sh
# Start OpenClaw Gateway (in separate terminal)
# openclaw gateway start
# Get gateway token
GATEWAY_TOKEN=$(openclaw config get gateway.auth.token)
# Start Studio
npx -y openclaw-studio@latest
cd openclaw-studio
# Configure via environment
export NEXT_PUBLIC_GATEWAY_URL=ws://localhost:18789
npm run dev
# Open http://localhost:3000
# Enter gateway token in UI
Example 2: Production Cloud Setup
#!/bin/bash
# deploy-studio-cloud.sh
# On your VPS running OpenClaw Gateway
cd /opt
npx -y openclaw-studio@latest
cd openclaw-studio
# Install dependencies
npm install
# Build for production
npm run build
# Set access token for public access
export STUDIO_ACCESS_TOKEN=$(openssl rand -hex 32)
# Expose over Tailscale
tailscale serve --yes --bg --https 443 http://127.0.0.1:3000
# Run production server
npm run start
echo "Studio available at: https://$(tailscale status --json | jq -r '.Self.DNSName')/"
echo "Access token: $STUDIO_ACCESS_TOKEN"
echo "Use: https://your-host.ts.net/?access_token=$STUDIO_ACCESS_TOKEN"
Example 3: Agent with Sandbox & Approvals
// create-secure-agent.ts
import { createAgent } from './lib/agent-api';
async function createSecureCodeAgent() {
const agent = await createAgent({
name: 'secure-code-assistant',
systemPrompt: `You are a secure code assistant.
Always ask before executing commands.
Work within the sandboxed environment only.`,
toolPolicy: 'allowlist',
allowedTools: [
'bash',
'read_file',
'write_file',
'search_files'
],
sandboxEnabled: true,
execApprovalRequired: true
});
console.log('Agent created:', agent.id);
return agent;
}
// Usage
createSecureCodeAgent().catch(console.error);
Example 4: Cron Job Configuration
// configure-cron-job.ts
interface CronJobConfig {
agentId: string;
schedule: string;
task: string;
enabled: boolean;
}
async function createCronJob(config: CronJobConfig) {
const response = await fetch('/api/intents/job-create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agent_id: config.agentId,
schedule: config.schedule,
task_prompt: config.task,
enabled: config.enabled
})
});
return response.json();
}
// Schedule daily backup
await createCronJob({
agentId: 'agent-123',
schedule: '0 0 * * *', // Daily at midnight
task: 'Create backup of project files and upload to storage',
enabled: true
});
Common Patterns
Pattern 1: Conditional Gateway URL
// lib/gateway-config.ts
export function getGatewayUrl(): string {
// Check environment variable first
if (process.env.NEXT_PUBLIC_GATEWAY_URL) {
return process.env.NEXT_PUBLIC_GATEWAY_URL;
}
// Check saved settings
const settings = loadStudioSettings();
if (settings?.gatewayUrl) {
return settings.gatewayUrl;
}
// Default to localhost
return 'ws://localhost:18789';
}
export function isSecureConnection(url: string): boolean {
return url.startsWith('wss://');
}
Pattern 2: Access Token Management
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const requiredToken = process.env.STUDIO_ACCESS_TOKEN;
// No token required for loopback-only binds
if (!requiredToken) {
return NextResponse.next();
}
// Check cookie first
const cookieToken = request.cookies.get('studio_access_token')?.value;
if (cookieToken === requiredToken) {
return NextResponse.next();
}
// Check query param (for initial setup)
const queryToken = request.nextUrl.searchParams.get('access_token');
if (queryToken === requiredToken) {
const response = NextResponse.next();
response.cookies.set('studio_access_token', requiredToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 30 // 30 days
});
return response;
}
return new NextResponse('Unauthorized', { status: 401 });
}
export const config = {
matcher: ['/api/:path*', '/dashboard/:path*']
};
Pattern 3: Runtime Event Handling
// lib/runtime-store.ts
import Database from 'better-sqlite3';
export class RuntimeStore {
private db: Database.Database;
private subscribers: Set<(event: RuntimeEvent) => void>;
constructor(dbPath: string) {
this.db = new Database(dbPath);
this.subscribers = new Set();
this.initDb();
}
private initDb() {
this.db.exec(`
CREATE TABLE IF NOT EXISTS runtime_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
agent_id TEXT,
payload TEXT NOT NULL,
timestamp INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_agent_timestamp
ON runtime_events(agent_id, timestamp);
`);
}
async storeEvent(event: RuntimeEvent) {
const stmt = this.db.prepare(`
INSERT INTO runtime_events (event_type, agent_id, payload, timestamp)
VALUES (?, ?, ?, ?)
`);
stmt.run(
event.type,
event.agentId,
JSON.stringify(event),
Date.now()
);
// Notify subscribers
this.subscribers.forEach(fn => fn(event));
}
async getHistory(agentId?: string, limit = 100): Promise<RuntimeEvent[]> {
const query = agentId
? `SELECT payload FROM runtime_events
WHERE agent_id = ?
ORDER BY timestamp DESC LIMIT ?`
: `SELECT payload FROM runtime_events
ORDER BY timestamp DESC LIMIT ?`;
const stmt = this.db.prepare(query);
const rows = agentId
? stmt.all(agentId, limit)
: stmt.all(limit);
return rows.map(row => JSON.parse(row.payload)).reverse();
}
subscribe(callback: (event: RuntimeEvent) => void) {
this.subscribers.add(callback);
return {
unsubscribe: () => this.subscribers.delete(callback)
};
}
}
Troubleshooting
Connection Issues
Problem: UI loads but "Connect" fails
# Check gateway is running
openclaw gateway status
# Verify gateway URL in Studio settings
cat ~/.openclaw/openclaw-studio/settings.json
# If Studio is remote, remember localhost means Studio host
# Use Tailscale Serve or SSH tunnel if needed
Problem: EPROTO / "wrong version number"
// Wrong: using wss:// to non-TLS endpoint
const url = 'wss://localhost:18789'; // ❌
// Right: use ws:// for plain HTTP
const url = 'ws://localhost:18789'; // ✅
// Right: use wss:// for Tailscale HTTPS
const url = 'wss://gateway.ts.net'; // ✅
Problem: Assets 404 under /studio
# Studio must be served at root path
# If you need /studio path:
# 1. Set basePath in next.config.js
# 2. Rebuild Studio
# next.config.js
module.exports = {
basePath: '/studio',
// ...
}
Native Module Issues
Problem: better_sqlite3.node / NODE_MODULE_VERSION mismatch
# Auto-repair (recommended)
npm run verify:native-runtime:repair
# Manual fix
npm rebuild better-sqlite3
npm install
# Check node/npm versions match
node -v && node -p "process.versions.modules"
which node && which npm
# If using nvm, ensure same node version
nvm use 20
npm install
Problem: SQLite errors on startup
# Check permissions on state directory
ls -la ~/.openclaw/openclaw-studio/
# Reset runtime DB if corrupted
rm ~/.openclaw/openclaw-studio/runtime.db
npm run dev
Access Token Issues
Problem: 401 "Studio access token required"
# Set access token for public binds
export STUDIO_ACCESS_TOKEN=your-secure-token
# Access UI with token once to set cookie
curl https://studio.ts.net/?access_token=your-secure-token
# Or set in browser
# https://studio.ts.net/?access_token=your-secure-token
Debugging Connection Path
// debug-connection.ts
export function debugConnection() {
console.log('Studio runtime environment:');
console.log('- Node version:', process.version);
console.log('- Platform:', process.platform);
console.log('- CWD:', process.cwd());
console.log('- State dir:', process.env.OPENCLAW_STATE_DIR || '~/.openclaw');
const isServer = typeof window === 'undefined';
console.log('- Running on:', isServer ? 'server' : 'browser');
if (isServer) {
const os = require('os');
console.log('- Hostname:', os.hostname());
console.log('- Network interfaces:',
Object.keys(os.networkInterfaces()));
}
console.log('\nGateway connection:');
console.log('- URL:', process.env.NEXT_PUBLIC_GATEWAY_URL || 'ws://localhost:18789');
console.log('- Token set:', !!process.env.GATEWAY_TOKEN);
console.log('- Access token required:', !!process.env.STUDIO_ACCESS_TOKEN);
}
Key Files Reference
openclaw-studio/
├── app/
│ ├── api/
│ │ ├── runtime/
│ │ │ └── stream/route.ts # SSE streaming endpoint
│ │ └── intents/
│ │ ├── agent-create/route.ts
│ │ ├── chat/route.ts
│ │ └── job-create/route.ts
│ ├── dashboard/
│ │ ├── page.tsx # Main dashboard
│ │ └── agents/page.tsx
│ └── layout.tsx
├── lib/
│ ├── runtime-store.ts # SQLite runtime DB
│ ├── gateway-client.ts # WebSocket client
│ └── settings.ts # Settings persistence
├── hooks/
│ └── useGatewayConnection.ts
├── docs/
│ ├── ui-guide.md
│ ├── pi-chat-streaming.md
│ ├── permissions-sandboxing.md
│ └── color-system.md
├── next.config.js
├── package.json
└── ARCHITECTURE.md
Additional Resources
- UI Guide:
docs/ui-guide.md- Agent creation, cron jobs, exec approvals - PI Chat Streaming:
docs/pi-chat-streaming.md- SSE event handling, replay, tool calls - Permissions:
docs/permissions-sandboxing.md- Tool policies, sandbox config, approval flows - Architecture:
ARCHITECTURE.md- Modules and data flow - Discord: https://discord.gg/EFkFHbZw