| name | openclaw-desk-pet-macos |
| description | Build and configure the OpenClaw Desk Pet, a native macOS desktop companion that visualizes AI agent activity with animated cat states. |
| triggers | ["set up openclaw desktop pet","configure openclaw desk sprite","integrate openclaw gateway visualization","build macos openclaw companion","create ai agent desktop pet","connect openclaw to desktop animation","customize openclaw desk pet quick tasks","troubleshoot openclaw desk pet connection"] |
OpenClaw Desk Pet macOS Skill
Skill by ara.so — Hermes Skills collection.
OpenClaw Desk Pet is a native macOS desktop companion that visualizes OpenClaw agent activity through an animated cat character. It listens to OpenClaw Gateway events and maps agent states (thinking, tool execution, idle, sleeping) to corresponding animations. Users can trigger tasks via quick-action buttons or text input, and see real-time status updates without switching to the console.
Installation
git clone https://github.com/LeoZhaorx/openclaw-desk-pet.git
cd openclaw-desk-pet
cp desk-sprite/.desk-sprite.env.example desk-sprite/.desk-sprite.env
chmod 600 desk-sprite/.desk-sprite.env
Requirements:
- macOS 13+
- Swift 5.9+ (Xcode Command Line Tools)
- Python 3.9+
- OpenClaw installed and configured
Configuration
Edit desk-sprite/.desk-sprite.env:
OPENCLAW_ROOT="$HOME/.openclaw"
OPENCLAW_GATEWAY_URL="ws://127.0.0.1:18789"
OPENCLAW_GATEWAY_TOKEN=""
OPENCLAW_ACTIVE_WINDOW_SECONDS="20"
OPENCLAW_START_SCRIPT=""
DESK_SPRITE_CONSOLE_PORT="17890"
DESK_SPRITE_ASSETS="./media/"
Token Discovery Order:
- Explicit
OPENCLAW_GATEWAY_TOKEN environment variable
- OpenClaw config gateway token
- OpenClaw
.env file
Key Commands
./start-desk-pet.command
./stop-desk-pet.command
./restart-desk-pet.command
cd desk-sprite/
./launch.sh
./halt.sh
./health.sh
Configuration Console
Access the local configuration panel at http://127.0.0.1:17890/ (only listens on localhost).
Features:
- Set OpenClaw root directory
- Configure Gateway URL and token
- Define startup script
- Adjust file fallback window
- Create and reorder quick-action buttons
- Auto-save and reload configuration
Swift Integration Patterns
State Machine Architecture
The desk pet uses a state coordinator that maps OpenClaw Gateway events to animation states:
enum DeskSpriteState {
case idle
case thinking
case taskStarting
case tooling
case completed
case sleeping
}
Gateway Event Handling
import Foundation
struct GatewayEvent: Codable {
let type: String
let data: [String: AnyCodable]
}
class StateCoordinator {
func processGatewayEvent(_ event: GatewayEvent) -> DeskSpriteState {
switch event.type {
case "agent":
if let status = event.data["status"]?.stringValue {
switch status {
case "thinking": return .thinking
case "tool_call": return .tooling
case "completed": return .completed
default: return .idle
}
}
case "chat":
return .taskStarting
default:
break
}
return .idle
}
}
Animation Transition Logic
import AVFoundation
class AnimationController {
private var currentSegment: AnimationSegment = .idleLoop
private var player: AVPlayer?
enum AnimationSegment {
case idleLoop, thinkingEnter, thinkingLoop
case toolingEnter, toolingLoop, toolingExit
case sleepEnter, sleepShallow, sleepDeep
}
func transitionTo(state: DeskSpriteState) {
let nextSegment = mapStateToSegment(state)
if canTransition(from: currentSegment, to: nextSegment) {
playSegment(nextSegment)
currentSegment = nextSegment
}
}
private func mapStateToSegment(_ state: DeskSpriteState) -> AnimationSegment {
switch state {
case .idle: return .idleLoop
case .thinking: return .thinkingEnter
case .tooling: return .toolingEnter
case .sleeping: return .sleepEnter
default: return currentSegment
}
}
}
File Fallback Mode
When Gateway is unavailable, the desk pet reads local session files:
import Foundation
class FileFallbackMonitor {
private let openclawRoot: String
private let activeWindowSeconds: Int
func getCurrentState() -> DeskSpriteState {
let sessionsPath = "\(openclawRoot)/sessions/sessions.json"
guard let data = try? Data(contentsOf: URL(fileURLWithPath: sessionsPath)),
let json = try? JSONDecoder().decode(SessionsFile.self, from: data) else {
return .idle
}
guard let recentSession = json.sessions.max(by: { $0.timestamp < $1.timestamp }) else {
return .idle
}
let now = Date()
let sessionTime = Date(timeIntervalSince1970: recentSession.timestamp)
let elapsed = now.timeIntervalSince(sessionTime)
if elapsed > Double(activeWindowSeconds) {
return .sleeping
}
return parseJSONLTail(sessionPath: recentSession.path)
}
private func parseJSONLTail(sessionPath: String) -> DeskSpriteState {
let jsonlPath = "\(openclawRoot)/sessions/\(sessionPath)/log.jsonl"
return .idle
}
}
Quick Task Configuration
Quick tasks are defined in the configuration and accessible via the desk pet UI:
struct QuickTask: Codable, Identifiable {
let id: UUID
let label: String
let prompt: String
let order: Int
}
let quickTasks = [
QuickTask(
id: UUID(),
label: "Check Email",
prompt: "Check my unread emails and summarize",
order: 1
),
QuickTask(
id: UUID(),
label: "Weather",
prompt: "What's the weather forecast for today?",
order: 2
),
QuickTask(
id: UUID(),
label: "Daily Brief",
prompt: "Generate my daily briefing with tasks and calendar",
order: 3
)
]
Sending Tasks from Desktop
import Foundation
class TaskDispatcher {
enum DispatchMethod {
case console
case cli
case gateway
}
func sendTask(_ prompt: String) async throws {
if let console = try? await sendToConsole(prompt) {
return
}
if let cli = try? await sendViaCLI(prompt) {
return
}
if let gateway = try? await sendToGateway(prompt) {
return
}
throw TaskDispatchError.allMethodsFailed
}
private func sendToGateway(_ prompt: String) async throws {
let url = URL(string: ProcessInfo.processInfo.environment["OPENCLAW_GATEWAY_URL"] ?? "")!
let token = ProcessInfo.processInfo.environment["OPENCLAW_GATEWAY_TOKEN"]
var request = URLRequest(url: url)
request.setValue("Bearer \(token ?? "")", forHTTPHeaderField: "Authorization")
let message = ["type": "chat", "content": prompt]
request.httpBody = try JSONEncoder().encode(message)
let (_, response) = try await URLSession.shared.data(for: request)
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
throw TaskDispatchError.gatewayFailed
}
}
}
Window Management
The desk pet uses a transparent, borderless window that persists across macOS Spaces:
import AppKit
class DeskPetWindowController: NSWindowController {
override func windowDidLoad() {
super.windowDidLoad()
guard let window = window else { return }
window.styleMask = [.borderless, .nonactivatingPanel]
window.isOpaque = false
window.backgroundColor = .clear
window.level = .floating
window.collectionBehavior = [.canJoinAllSpaces, .stationary]
window.isMovableByWindowBackground = true
if let savedFrame = UserDefaults.standard.string(forKey: "DeskPetWindowFrame") {
window.setFrameFromString(savedFrame)
}
}
func savePosition() {
if let window = window {
let frameString = window.frameString
UserDefaults.standard.set(frameString, forKey: "DeskPetWindowFrame")
}
}
}
Tool Label Display
When OpenClaw executes tools, the desk pet shows corresponding labels:
struct ToolBubbleView: View {
let toolType: String
var body: some View {
Text(toolLabel)
.font(.system(size: 10, weight: .medium))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
Capsule()
.fill(toolColor.opacity(0.9))
)
.foregroundColor(.white)
}
private var toolLabel: String {
switch toolType {
case "exec": return "exec"
case "web_browser", "web": return "web"
case "sessions_spawn": return "spawn"
case "file_read", "file_write": return "file"
default: return toolType
}
}
private var toolColor: Color {
switch toolType {
case "exec": return .blue
case "web_browser", "web": return .green
case "sessions_spawn": return .purple
default: return .gray
}
}
}
Result Bubble Display
Task results are shown in a chat-like bubble with cleaned formatting:
struct ResultBubbleView: View {
let content: String
var body: some View {
VStack(alignment: .leading, spacing: 8) {
ForEach(cleanedSegments, id: \.self) { segment in
Text(segment)
.font(.system(size: 12))
.padding(10)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color.white.opacity(0.95))
)
.foregroundColor(.primary)
}
}
}
private var cleanedSegments: [String] {
let cleaned = content
.replacingOccurrences(of: #"\[THINKING\]"#, with: "", options: .regularExpression)
.replacingOccurrences(of: #"\[TOOL:.*?\]"#, with: "", options: .regularExpression)
.trimmingCharacters(in: .whitespacesAndNewlines)
return cleaned.components(separatedBy: "\n\n").filter { !$0.isEmpty }
}
}
Troubleshooting
Desk Pet Not Connecting to Gateway
openclaw gateway status
echo $OPENCLAW_GATEWAY_URL
curl -H "Authorization: Bearer $OPENCLAW_GATEWAY_TOKEN" \
http://127.0.0.1:18789/health
tail -f desk-sprite/logs/desk-sprite.log
Animation Not Playing
let assetsPath = ProcessInfo.processInfo.environment["DESK_SPRITE_ASSETS"]!
print("Assets path: \(assetsPath)")
let requiredFiles = [
"idle-loop.mov",
"thinking-enter.mov",
"thinking-loop.mov",
"tooling-enter.mov",
"tooling-loop.mov",
"sleep-shallow.mov",
"sleep-deep.mov"
]
for file in requiredFiles {
let path = "\(assetsPath)/\(file)"
if !FileManager.default.fileExists(atPath: path) {
print("Missing: \(file)")
}
}
Quick Tasks Not Sending
which openclaw
openclaw chat "Test message"
Configuration Changes Not Applied
./restart-desk-pet.command
open http://127.0.0.1:17890/
ls -l desk-sprite/.desk-sprite.env
File Fallback Mode Not Working
let root = ProcessInfo.processInfo.environment["OPENCLAW_ROOT"]!
print("OpenClaw root: \(root)")
let sessionsPath = "\(root)/sessions/sessions.json"
if FileManager.default.isReadableFile(atPath: sessionsPath) {
print("sessions.json is accessible")
} else {
print("Cannot read sessions.json at \(sessionsPath)")
}
let activeWindow = ProcessInfo.processInfo.environment["OPENCLAW_ACTIVE_WINDOW_SECONDS"]!
print("Active window: \(activeWindow)s")
Common Patterns
Custom Animation States
Add custom states by extending the state machine:
enum DeskSpriteState {
case idle
case thinking
case tooling
case completed
case sleeping
case custom(String)
}
func mapEventToState(_ event: GatewayEvent) -> DeskSpriteState {
if event.type == "custom_event" {
return .custom("myState")
}
}
Persistent Window Position Across Launches
class DeskPetWindowController: NSWindowController {
private let positionKey = "DeskPetWindowFrame"
override func windowDidLoad() {
super.windowDidLoad()
restorePosition()
NotificationCenter.default.addObserver(
self,
selector: #selector(windowDidMove),
name: NSWindow.didMoveNotification,
object: window
)
}
@objc func windowDidMove(_ notification: Notification) {
savePosition()
}
private func restorePosition() {
if let savedFrame = UserDefaults.standard.string(forKey: positionKey) {
window?.setFrameFromString(savedFrame)
}
}
private func savePosition() {
if let window = window {
UserDefaults.standard.set(window.frameString, forKey: positionKey)
}
}
}
Dynamic Quick Task Loading
struct QuickTaskManager {
private let configPath: String
func loadQuickTasks() -> [QuickTask] {
guard let data = try? Data(contentsOf: URL(fileURLWithPath: configPath)),
let tasks = try? JSONDecoder().decode([QuickTask].self, from: data) else {
return defaultQuickTasks()
}
return tasks.sorted { $0.order < $1.order }
}
func saveQuickTasks(_ tasks: [QuickTask]) throws {
let data = try JSONEncoder().encode(tasks)
try data.write(to: URL(fileURLWithPath: configPath))
}
private func defaultQuickTasks() -> [QuickTask] {
[
QuickTask(id: UUID(), label: "Check Email", prompt: "Summarize unread emails", order: 1),
QuickTask(id: UUID(), label: "Weather", prompt: "Today's weather forecast", order: 2)
]
}
}
This skill provides comprehensive guidance for AI coding agents to help developers install, configure, and extend the OpenClaw Desk Pet macOS application.