Loading...
Loading...
Expert in OpenClaw Studio - web dashboard for managing OpenClaw Gateway, agents, chat, approvals, and jobs
npx skill4agent add aradotso/hermes-skills openclaw-studio-dashboardSkill by ara.so — Hermes Skills collection.
# Run latest version with npx
npx -y openclaw-studio@latest
# Opens on http://localhost:3000
# Default gateway URL: ws://localhost:18789git clone https://github.com/grp06/openclaw-studio.git
cd openclaw-studio
npm install
npm run dev# Configure gateway URL/token without opening UI
npm run studio:setup/api/runtime/*/api/intents/*ws://localhost:18789# 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>openclaw config get gateway.auth.tokentailscale serve --yes --bg --https 443 http://127.0.0.1:18789wss://<gateway-host>.ts.net<gateway-token>ssh -L 18789:127.0.0.1:18789 user@<gateway-host>ws://localhost:18789<gateway-token># 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# 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~/.openclaw/openclaw-studio/~/.openclaw/
├── openclaw.json # OpenClaw Gateway config
└── openclaw-studio/
├── settings.json # Gateway URL/token
└── runtime.db # Control-plane runtime DB# 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// 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 onClick={handleConnect} disabled={connected}>
{connected ? 'Connected' : 'Connect'}
</button>
<span>Status: {status}</span>
</div>
);
}// 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'
}
});
}// 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();
}// 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 onKeyPress={(e) => {
if (e.key === 'Enter') {
sendMessage(e.currentTarget.value);
e.currentTarget.value = '';
}
}} />
</div>
);
}#!/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#!/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"// 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);// 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
});// 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://');
}// 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*']
};// 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)
};
}
}# 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 neededEPROTO// 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'; // ✅/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',
// ...
}better_sqlite3.nodeNODE_MODULE_VERSION# 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# Check permissions on state directory
ls -la ~/.openclaw/openclaw-studio/
# Reset runtime DB if corrupted
rm ~/.openclaw/openclaw-studio/runtime.db
npm run dev# 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// 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);
}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.mddocs/ui-guide.mddocs/pi-chat-streaming.mddocs/permissions-sandboxing.mdARCHITECTURE.md