- WebSocket Protocol
- Electron IPC Channels
- Preload API (window.lucy)
- Agent Python API
- Configuration Schema
Bidirectional JSON messages between the Host (Electron app) and the Agent (Python, runs in VM).
Connection: ws://127.0.0.1:<port> (port is per-agent, starting at 8765)
Max payload: 50 MB
Ping/Pong: 30s interval, 10s timeout
Every message is a JSON object with two fields:
{
"type": "<message_type>",
"payload": { ... }
}Health check. Agent responds with pong.
{ "type": "ping", "payload": {} }Start a new task. Rejected if a task is already running.
{
"type": "execute_task",
"payload": {
"description": "Open LibreOffice Writer and write a letter"
}
}Cancel the currently running task.
{ "type": "cancel_task", "payload": {} }Replay a saved recipe. Accepts a full path or bare task ID.
{
"type": "replay_task",
"payload": {
"path": "a1b2c3d4e5f6",
"mode": "intelligent"
}
}| Field | Values |
|---|---|
path |
Task ID or full path to YAML file |
mode |
"intelligent" or "deterministic" |
Install software in the VM. Internally executed as an agent task.
{
"type": "install_software",
"payload": { "name": "vlc" }
}Request all saved recipes. Agent responds with task_list.
{ "type": "list_tasks", "payload": {} }Delete a saved recipe by ID.
{
"type": "delete_task",
"payload": { "id": "a1b2c3d4e5f6" }
}Update a recipe's description.
{
"type": "update_task",
"payload": {
"id": "a1b2c3d4e5f6",
"description": "Updated description"
}
}Set per-recipe instructions.
{
"type": "update_task_instructions",
"payload": {
"id": "a1b2c3d4e5f6",
"instructions": "Use filename report.pdf"
}
}Export a recipe as raw YAML. Agent responds with task_export.
{
"type": "export_task",
"payload": { "id": "a1b2c3d4e5f6" }
}Import a recipe from YAML content. Agent responds with task_imported.
{
"type": "import_task",
"payload": { "yaml": "task:\n id: ..." }
}Request a single screenshot. Agent responds with screenshot_result.
{ "type": "screenshot", "payload": {} }Add a learned lesson.
{
"type": "add_lesson",
"payload": {
"task_id": "a1b2c3d4e5f6",
"feedback": "Always use apt instead of snap"
}
}Request all lessons. Agent responds with lessons_list.
{ "type": "list_lessons", "payload": {} }Delete a lesson by ID.
{
"type": "delete_lesson",
"payload": { "id": "abc12345" }
}Response to ping.
{ "type": "pong", "payload": {} }Real-time event during task execution. Streamed continuously.
{
"type": "event",
"payload": {
"event_type": "action",
"task_id": "a1b2c3d4e5f6",
"step": 5,
"data": {
"tool": "computer",
"input": { "action": "left_click", "coordinate": [640, 400] },
"result": "Clicked at (640, 400)"
},
"timestamp": 1700000005.0
}
}Event types:
event_type |
data fields |
Description |
|---|---|---|
screenshot |
base64 (string) |
Screenshot taken (base64 PNG) |
action |
tool, input, result |
Tool action executed |
thinking |
text (string) |
Claude's reasoning text |
status |
message, optionally description |
Task started / cancelled |
error |
message, optionally tool, input |
Error during execution |
complete |
message, total_steps |
Task finished successfully |
Response to list_tasks.
{
"type": "task_list",
"payload": {
"tasks": [
{
"id": "a1b2c3d4e5f6",
"description": "Open LibreOffice...",
"instructions": "",
"status": "completed",
"duration_seconds": 32.5,
"created_at": 1700000000.0
}
]
}
}Confirmation of delete_task.
{
"type": "task_deleted",
"payload": { "id": "a1b2c3d4e5f6" }
}Confirmation of update_task.
{
"type": "task_updated",
"payload": { "id": "a1b2c3d4e5f6", "description": "Updated" }
}Confirmation of update_task_instructions.
{
"type": "task_instructions_updated",
"payload": { "id": "a1b2c3d4e5f6" }
}Response to export_task.
{
"type": "task_export",
"payload": {
"id": "a1b2c3d4e5f6",
"yaml": "task:\n id: a1b2c3d4e5f6\n ..."
}
}Response to import_task. Returns the imported task's metadata.
{
"type": "task_imported",
"payload": {
"id": "a1b2c3d4e5f6",
"description": "...",
"instructions": "",
"status": "completed",
"duration_seconds": 32.5,
"created_at": 1700000000.0
}
}Response to screenshot.
{
"type": "screenshot_result",
"payload": {
"base64": "<base64-encoded PNG>",
"width": 1280,
"height": 800
}
}Confirmation of add_lesson.
{
"type": "lesson_added",
"payload": {
"id": "abc12345",
"task_id": "a1b2c3d4e5f6",
"feedback": "Always use apt instead of snap"
}
}Response to list_lessons.
{
"type": "lessons_list",
"payload": {
"lessons": [
{
"id": "abc12345",
"task_id": "a1b2c3d4e5f6",
"feedback": "Always use apt instead of snap",
"created_at": 1700000000.0
}
]
}
}Confirmation of delete_lesson.
{
"type": "lesson_deleted",
"payload": { "id": "abc12345" }
}Error response for any failed request.
{
"type": "error",
"payload": {
"message": "Eine Aufgabe lauft bereits"
}
}- One task at a time. A second
execute_taskwhile one is running returns anerror. install_softwareis internally converted to a task, so the same one-at-a-time constraint applies.- Screenshots in events are embedded as base64 inside
eventpayloads. These can be several MB. - Max 100 steps per task (configurable via
SA_MAX_AGENT_STEPS).
| Channel | Arguments | Return | Description |
|---|---|---|---|
agents:create |
opts: { name, apiKey, model, apiBaseUrl? } |
AgentInstance |
Create a new agent |
agents:remove |
agentId: string |
void |
Remove agent and container |
agents:list |
-- | AgentInstance[] |
List all agents |
agents:connect |
agentId: string |
void |
Connect WebSocket |
agents:disconnect |
agentId: string |
void |
Disconnect WebSocket |
agents:update |
agentId, updates: { name?, apiKey?, model?, apiBaseUrl? } |
AgentInstance | null |
Update agent settings |
agents:get |
agentId: string |
AgentInstance | null |
Get single agent |
agents:state |
agentId: string |
"running" | "stopped" | "not_found" |
Docker container state |
task:execute |
agentId, description: string |
void |
Start a task |
task:cancel |
agentId: string |
void |
Cancel running task |
task:list |
agentId: string |
Array<Record> |
List saved recipes |
task:replay |
agentId, path, mode: string |
void |
Replay a recipe |
task:delete |
agentId, id: string |
void |
Delete a recipe |
task:update |
agentId, id, description: string |
void |
Update recipe description |
task:updateInstructions |
agentId, id, instructions: string |
void |
Update recipe instructions |
task:export |
agentId, id: string |
{ id, yaml } |
Export recipe YAML |
task:import |
agentId, yaml: string |
void |
Import recipe YAML |
software:install |
agentId, name: string |
void |
Install software |
screenshot |
agentId: string |
{ base64, width, height } |
Request screenshot |
lessons:add |
agentId, taskId, feedback: string |
void |
Add a lesson |
lessons:list |
agentId: string |
Array<Record> |
List lessons |
lessons:delete |
agentId, id: string |
void |
Delete a lesson |
config:load |
-- | AppConfig |
Load full config |
config:update |
partial: Partial<AppConfig> |
AppConfig |
Update config |
schedule:list |
agentId?: string |
Schedule[] |
List schedules |
schedule:add |
{ recipeId, agentId, cronExpr, mode } |
Schedule |
Add schedule |
schedule:update |
id, updates |
Schedule | null |
Update schedule |
schedule:remove |
id: string |
void |
Remove schedule |
| Channel | Arguments | Description |
|---|---|---|
agent:event |
agentId: string, event: AgentEvent |
Real-time agent event |
agent:connected |
agentId: string |
Agent WebSocket connected |
agent:disconnected |
agentId: string, reason: string |
Agent WebSocket disconnected |
schedule:executed |
scheduleId: string, agentId: string |
Scheduled recipe executed |
schedule:missed |
scheduleId: string, agentId: string |
Scheduled run skipped (agent disconnected) |
The preload script exposes a typed API on window.lucy via Electron's contextBridge. This is the only way renderer code communicates with the main process.
agents: {
create(opts: { name: string; apiKey: string; model: string; apiBaseUrl?: string }): Promise<AgentInstance>;
remove(agentId: string): Promise<void>;
list(): Promise<AgentInstance[]>;
connect(agentId: string): Promise<void>;
disconnect(agentId: string): Promise<void>;
state(agentId: string): Promise<"running" | "stopped" | "not_found">;
update(agentId: string, updates: { name?: string; apiKey?: string; model?: string; apiBaseUrl?: string }): Promise<AgentInstance | null>;
get(agentId: string): Promise<AgentInstance | null>;
}task: {
execute(agentId: string, description: string): Promise<void>;
cancel(agentId: string): Promise<void>;
list(agentId: string): Promise<TaskMeta[]>;
replay(agentId: string, path: string, mode: "intelligent" | "deterministic"): Promise<void>;
delete(agentId: string, id: string): Promise<void>;
update(agentId: string, id: string, description: string): Promise<void>;
updateInstructions(agentId: string, id: string, instructions: string): Promise<void>;
export(agentId: string, id: string): Promise<{ id: string; yaml: string }>;
import(agentId: string, yaml: string): Promise<void>;
}software: {
install(agentId: string, name: string): Promise<void>;
}lessons: {
add(agentId: string, taskId: string, feedback: string): Promise<void>;
list(agentId: string): Promise<Lesson[]>;
delete(agentId: string, id: string): Promise<void>;
}config: {
load(): Promise<AppConfig>;
update(partial: Partial<AppConfig>): Promise<AppConfig>;
}schedule: {
list(agentId?: string): Promise<Schedule[]>;
add(data: { recipeId: string; agentId: string; cronExpr: string; mode: string }): Promise<Schedule>;
update(id: string, updates: Partial<Schedule>): Promise<Schedule | null>;
remove(id: string): Promise<void>;
}screenshot(agentId: string): Promise<{ base64: string; width: number; height: number }>;Each returns an unsubscribe function.
on: {
agentEvent(cb: (agentId: string, event: AgentEvent) => void): () => void;
agentConnected(cb: (agentId: string) => void): () => void;
agentDisconnected(cb: (agentId: string, reason: string) => void): () => void;
}Pydantic settings loaded from environment variables with SA_ prefix.
class AgentSettings(BaseSettings):
anthropic_api_key: str # Required
claude_model: str # Default: "claude-sonnet-4-20250514"
max_tokens: int # Default: 4096
max_agent_steps: int # Default: 100
display: str # Default: ":1"
screen_width: int # Default: 1280
screen_height: int # Default: 800
screenshot_max_dim: int # Default: 1280
ws_host: str # Default: "0.0.0.0"
ws_port: int # Default: 8765
record_all_tasks: bool # Default: True
tasks_dir: Path # Default: Path("/home/agent/tasks")
lessons_file: Path # Default: Path("/home/agent/tasks/lessons.yaml")
log_level: str # Default: "INFO"The main orchestrator class.
class Agent:
def __init__(self, settings: AgentSettings, lessons: LessonsStore | None = None) -> None: ...
@property
def busy(self) -> bool: ...
def cancel(self) -> None:
"""Request cancellation of the currently running task."""
def force_reset(self) -> None:
"""Force-reset the busy flag."""
async def execute_task(self, description: str) -> AsyncIterator[AgentEvent]:
"""Run a task. Yields AgentEvent objects for real-time progress."""@dataclass
class AgentEvent:
type: EventType # screenshot, action, thinking, status, error, complete
task_id: str
step: int
data: dict[str, Any]
timestamp: floatManages conversation history and Claude API calls.
class ClaudeClient:
def __init__(self, settings: AgentSettings, lessons_prompt: str = "") -> None: ...
def reset(self) -> None:
"""Clear conversation history for a new task."""
async def send(self, screenshot_b64: str | None = None) -> ParsedResponse:
"""Send conversation to Claude and return parsed response."""
def add_user_message(self, text: str) -> None:
"""Add a plain-text user message."""
def add_tool_results(self, results: list[BetaToolResultBlockParam]) -> None:
"""Add tool-result blocks as a user turn."""
def update_lessons(self, lessons_prompt: str) -> None:
"""Update the lessons section in the system prompt."""
@staticmethod
def make_tool_result(
tool_use_id: str,
output: str | None = None,
error: str | None = None,
screenshot_b64: str | None = None,
) -> BetaToolResultBlockParam:
"""Build a tool_result block."""@dataclass
class ParsedResponse:
text_blocks: list[ParsedTextBlock]
tool_calls: list[ParsedToolCall]
stop_reason: str | None
task_complete: bool # True if "AUFGABE ABGESCHLOSSEN" found in textControls the VM desktop via X11 tools. All methods are async.
class ComputerController:
def __init__(self, settings: AgentSettings) -> None: ...
async def screenshot(self) -> tuple[str, ScalingInfo]:
"""Take screenshot, downscale, return (base64_png, scaling)."""
async def mouse_click(self, x: int, y: int, button: int = 1) -> str:
async def mouse_double_click(self, x: int, y: int, button: int = 1) -> str:
async def mouse_drag(self, start_x: int, start_y: int, end_x: int, end_y: int, button: int = 1) -> str:
async def scroll(self, x: int, y: int, direction: str, clicks: int = 3) -> str:
async def type_text(self, text: str) -> str:
"""Type text. Falls back to xclip + Ctrl+V for non-ASCII."""
async def key_press(self, key: str) -> str:
async def hotkey(self, *keys: str) -> str:
async def execute(self, command: str, timeout: float = 120.0) -> str:
"""Execute a shell command. Commands ending with '&' run in background."""
async def wait_for_window(self, name: str, timeout: float = 30.0) -> str:
async def get_clipboard(self) -> str:
async def focus_window(self, name: str) -> str:@dataclass(frozen=True)
class ScalingInfo:
real_width: int
real_height: int
scaled_width: int
scaled_height: int
def to_real(self, x: int, y: int) -> tuple[int, int]:
"""Convert Claude-visible coords to real screen coords."""Records task executions as YAML playbooks.
class TaskRecorder:
def __init__(self, settings: AgentSettings) -> None: ...
def start(self, task_id: str, description: str) -> None:
def add_step(self, tool: str, action: str, params: dict, result: str) -> None:
def finish(self, status: str = "completed") -> Path | None:
@staticmethod
def load(path: Path) -> dict[str, Any]:
def list_tasks(self) -> list[dict[str, Any]]:
def delete_task(self, task_id: str) -> bool:
def get_task_yaml(self, task_id: str) -> str | None:
def import_task_yaml(self, yaml_content: str) -> dict[str, Any] | None:
def update_task_instructions(self, task_id: str, instructions: str) -> bool:
def update_task_description(self, task_id: str, description: str) -> bool:Manages persistent lessons for the agent.
class LessonsStore:
def __init__(self, settings: AgentSettings) -> None: ...
def add(self, task_id: str, feedback: str) -> Lesson:
def remove(self, lesson_id: str) -> bool:
def list_all(self) -> list[dict[str, Any]]:
def build_prompt_section(self) -> str:
"""Build prompt section from all lessons. Returns empty string if none."""Routes incoming WebSocket messages to handlers.
class MessageRouter:
def __init__(self, settings: AgentSettings) -> None: ...
async def handle(self, ws: ServerConnection, raw: str | bytes) -> None:
async def cleanup(self) -> None:
"""Cancel running task on disconnect."""def make_computer_tool(width: int, height: int, display_number: int = 1) -> BetaToolComputerUse20250124Param:
def get_tools(width: int, height: int) -> list[BetaToolUnionParam]:
BASH_TOOL: BetaToolBash20250124Param
TEXT_EDITOR_TOOL: BetaToolTextEditor20250124Paraminterface AppConfig {
vm: VmConfig;
agent: AgentConfig;
anthropic: AnthropicConfig;
window: WindowState;
agents: AgentInstance[];
nextPortOffset: number;
theme: "dark" | "light";
language: "de" | "en";
schedules: Schedule[];
}interface AgentInstance {
id: string; // 8-char UUID prefix
name: string;
wsPort: number;
vncPort: number;
sshPort: number;
containerName: string; // "lucy-agent-<id>"
createdAt: number; // timestamp ms
apiKey: string;
model: string;
apiBaseUrl: string; // empty = Anthropic default
}interface Schedule {
id: string; // 8-char UUID prefix
recipeId: string; // task ID
agentId: string;
mode: "intelligent" | "deterministic";
cronExpr: string; // "daily:HH:MM" | "weekly:DOW:HH:MM" | "interval:MINUTES"
enabled: boolean;
lastRunAt: number | null;
nextRunAt: number; // timestamp ms
createdAt: number;
}interface VmConfig {
ramMb: number; // Default: 4096
cpus: number; // Default: 2
diskSizeGb: number; // Default: 20
vncPassword: string; // Default: "sandbox"
}interface AgentConfig {
maxReconnectAttempts: number; // Default: 20
reconnectIntervalMs: number; // Default: 3000
maxAgentSteps: number; // Default: 100
}interface AnthropicConfig {
apiKey: string; // Default: ""
model: string; // Default: "claude-sonnet-4-20250514"
maxTokens: number; // Default: 4096
}task:
id: string # 12-char hex UUID prefix
description: string # Natural language task description
instructions: string # Per-recipe replay instructions (can be empty)
created_at: float # Unix timestamp
status: string # "completed" | "cancelled" | "running"
duration_seconds: float
steps:
- step: int
tool: string # "computer" | "bash" | "str_replace_editor"
action: string # Tool-specific action name
params: object # Tool-specific parameters
result: string # Execution result text
timestamp: float# /home/agent/tasks/lessons.yaml
- id: string # 8-char hex UUID prefix
task_id: string # Associated task ID (or empty for manual lessons)
feedback: string # The lesson text
created_at: float # Unix timestamp