Files
droidclaw/docs/plans/2026-02-17-option1-implementation-plan.md
Sanju Sivalingam c395f9d83e feat: add DB persistence, real-time WebSocket, goal preprocessor, and Android companion app
- Add device/session/step DB persistence in server agent loop
- Add goal preprocessor for compound goals (e.g., "open YouTube and search X")
- Add step-level logging to agent loop
- Fix dashboard WebSocket auth (direct DB token lookup instead of auth.api)
- Fix web layout to use locals.session.token instead of cookie
- Add dashboard-ws.svelte.ts WebSocket store with auto-reconnect
- Rewrite devices page with direct DB queries and real-time updates
- Add device detail page with live step display and session history
- Add Android companion app resources, themes, and screen capture consent

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 20:12:41 +05:30

63 KiB

Option 1: Web + Backend Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Build the SvelteKit dashboard and Hono.js backend so users can sign up, manage API keys, configure LLM providers, connect Android devices via WebSocket, and run the DroidClaw agent loop from the browser.

Architecture: Monorepo with packages/shared/ (types), server/ (Hono + Bun WebSocket + agent loop), and web/ (SvelteKit dashboard). Both services share the same Postgres via Drizzle. Better Auth handles user auth (SvelteKit) and API key verification (Hono). The agent loop runs server-side, sending commands to connected phones via WebSocket.

Tech Stack: SvelteKit 2 (Svelte 5, node adapter), Hono.js (Bun), Drizzle ORM, Postgres, Better Auth (apiKey plugin), Tailwind v4, Valibot, TypeScript.

Design doc: docs/plans/2026-02-17-option1-web-backend-design.md


Task 1: Shared Types Package

Files:

  • Create: packages/shared/package.json
  • Create: packages/shared/tsconfig.json
  • Create: packages/shared/src/index.ts
  • Create: packages/shared/src/types.ts
  • Create: packages/shared/src/protocol.ts

Step 1: Create package.json

{
  "name": "@droidclaw/shared",
  "version": "0.0.1",
  "type": "module",
  "exports": {
    ".": "./src/index.ts"
  },
  "scripts": {
    "typecheck": "tsc --noEmit"
  },
  "devDependencies": {
    "typescript": "^5.9.2"
  }
}

Step 2: Create tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "declaration": true,
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src/**/*.ts"]
}

Step 3: Create types.ts

Port the core types from src/sanitizer.ts (UIElement) and src/actions.ts (ActionDecision, ActionResult) into shared types that work for both ADB and WebSocket connections.

// packages/shared/src/types.ts

export interface UIElement {
  id: string;
  text: string;
  type: string;
  bounds: string;
  center: [number, number];
  size: [number, number];
  clickable: boolean;
  editable: boolean;
  enabled: boolean;
  checked: boolean;
  focused: boolean;
  selected: boolean;
  scrollable: boolean;
  longClickable: boolean;
  password: boolean;
  hint: string;
  action: "tap" | "type" | "longpress" | "scroll" | "read";
  parent: string;
  depth: number;
}

export interface ActionDecision {
  action: string;
  coordinates?: [number, number];
  text?: string;
  direction?: string;
  reason?: string;
  package?: string;
  activity?: string;
  uri?: string;
  extras?: Record<string, string>;
  command?: string;
  filename?: string;
  think?: string;
  plan?: string[];
  planProgress?: string;
  skill?: string;
  query?: string;
  url?: string;
  path?: string;
  source?: string;
  dest?: string;
  code?: number;
  setting?: string;
}

export interface ActionResult {
  success: boolean;
  message: string;
  data?: string;
}

export interface DeviceInfo {
  model: string;
  androidVersion: string;
  screenWidth: number;
  screenHeight: number;
}

export interface ScreenState {
  elements: UIElement[];
  screenshot?: string; // base64 PNG
  packageName?: string;
  fallbackReason?: string;
}

Step 4: Create protocol.ts

// packages/shared/src/protocol.ts

import type { UIElement, ActionResult, DeviceInfo } from "./types.js";

// --- Device -> Server messages ---

export type DeviceMessage =
  | { type: "auth"; apiKey: string; deviceInfo?: DeviceInfo }
  | { type: "screen"; requestId: string; elements: UIElement[]; screenshot?: string; packageName?: string }
  | { type: "result"; requestId: string; success: boolean; error?: string; data?: string }
  | { type: "goal"; text: string }
  | { type: "pong" };

// --- Server -> Device messages ---

export type ServerToDeviceMessage =
  | { type: "auth_ok"; deviceId: string }
  | { type: "auth_error"; message: string }
  | { type: "get_screen"; requestId: string }
  | { type: "tap"; requestId: string; x: number; y: number }
  | { type: "type"; requestId: string; text: string }
  | { type: "swipe"; requestId: string; x1: number; y1: number; x2: number; y2: number; duration?: number }
  | { type: "enter"; requestId: string }
  | { type: "back"; requestId: string }
  | { type: "home"; requestId: string }
  | { type: "longpress"; requestId: string; x: number; y: number }
  | { type: "launch"; requestId: string; packageName: string }
  | { type: "clear"; requestId: string }
  | { type: "clipboard_set"; requestId: string; text: string }
  | { type: "clipboard_get"; requestId: string }
  | { type: "paste"; requestId: string }
  | { type: "open_url"; requestId: string; url: string }
  | { type: "switch_app"; requestId: string; packageName: string }
  | { type: "notifications"; requestId: string }
  | { type: "keyevent"; requestId: string; code: number }
  | { type: "open_settings"; requestId: string }
  | { type: "wait"; requestId: string; duration?: number }
  | { type: "ping" }
  | { type: "goal_started"; sessionId: string; goal: string }
  | { type: "goal_completed"; sessionId: string; success: boolean; stepsUsed: number };

// --- Server -> Dashboard messages ---

export type DashboardMessage =
  | { type: "device_online"; deviceId: string; name: string }
  | { type: "device_offline"; deviceId: string }
  | { type: "step"; sessionId: string; step: number; action: Record<string, unknown>; reasoning: string; screenHash: string }
  | { type: "goal_started"; sessionId: string; goal: string; deviceId: string }
  | { type: "goal_completed"; sessionId: string; success: boolean; stepsUsed: number };

Step 5: Create index.ts (barrel export)

export * from "./types.js";
export * from "./protocol.js";

Step 6: Install dependencies and verify typecheck

cd packages/shared && bun install && bun run typecheck

Step 7: Commit

git add packages/shared
git commit -m "feat: add @droidclaw/shared types package"

Task 2: Hono Server Scaffolding

Files:

  • Create: server/package.json
  • Create: server/tsconfig.json
  • Create: server/src/index.ts
  • Create: server/src/env.ts
  • Create: server/src/db.ts
  • Create: server/src/auth.ts
  • Create: server/.env.example
  • Create: server/Dockerfile

Step 1: Create package.json

{
  "name": "@droidclaw/server",
  "version": "0.0.1",
  "type": "module",
  "scripts": {
    "dev": "bun --watch src/index.ts",
    "start": "bun src/index.ts",
    "typecheck": "tsc --noEmit",
    "db:push": "drizzle-kit push",
    "db:generate": "drizzle-kit generate",
    "db:migrate": "drizzle-kit migrate"
  },
  "dependencies": {
    "hono": "^4.7.0",
    "better-auth": "^1.3.27",
    "drizzle-orm": "^0.44.5",
    "postgres": "^3.4.7"
  },
  "devDependencies": {
    "@types/bun": "^1.1.0",
    "drizzle-kit": "^0.31.4",
    "typescript": "^5.9.2"
  }
}

Step 2: Create tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "dist",
    "rootDir": "src",
    "types": ["bun-types"],
    "paths": {
      "@droidclaw/shared": ["../packages/shared/src"]
    }
  },
  "include": ["src/**/*.ts"]
}

Step 3: Create .env.example

DATABASE_URL="postgres://user:password@host:port/db-name"
PORT=8080
CORS_ORIGIN="http://localhost:5173"

Step 4: Create env.ts

// server/src/env.ts

export const env = {
  DATABASE_URL: process.env.DATABASE_URL!,
  PORT: parseInt(process.env.PORT || "8080"),
  CORS_ORIGIN: process.env.CORS_ORIGIN || "http://localhost:5173",
};

if (!env.DATABASE_URL) {
  throw new Error("DATABASE_URL is not set");
}

Step 5: Create db.ts

// server/src/db.ts

import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import { env } from "./env.js";

const client = postgres(env.DATABASE_URL);
export const db = drizzle(client);

Step 6: Create auth.ts

Better Auth instance with apiKey plugin, pointing to same Postgres. No sveltekitCookies — Hono uses its own session middleware.

// server/src/auth.ts

import { betterAuth } from "better-auth";
import { apiKey } from "better-auth/plugins";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "./db.js";

export const auth = betterAuth({
  database: drizzleAdapter(db, {
    provider: "pg",
  }),
  plugins: [apiKey()],
});

Step 7: Create index.ts

Minimal Hono app with Better Auth handler, CORS, health check. WebSocket upgrade via Bun.serve.

// server/src/index.ts

import { Hono } from "hono";
import { cors } from "hono/cors";
import { auth } from "./auth.js";
import { env } from "./env.js";

const app = new Hono();

// CORS for dashboard
app.use(
  "*",
  cors({
    origin: env.CORS_ORIGIN,
    allowHeaders: ["Content-Type", "Authorization"],
    allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
    credentials: true,
  })
);

// Better Auth handler
app.on(["POST", "GET"], "/api/auth/*", (c) => {
  return auth.handler(c.req.raw);
});

// Health check
app.get("/health", (c) => c.json({ status: "ok" }));

// Start server with WebSocket support
const server = Bun.serve({
  port: env.PORT,
  fetch: app.fetch,
  websocket: {
    open(ws) {
      console.log("WebSocket connected");
    },
    message(ws, message) {
      // placeholder — Task 4 implements device/dashboard handlers
    },
    close(ws) {
      console.log("WebSocket disconnected");
    },
  },
});

console.log(`Server running on port ${server.port}`);

Step 8: Create Dockerfile

FROM oven/bun:1

WORKDIR /app

COPY packages/shared ./packages/shared
COPY server ./server

WORKDIR /app/server
RUN bun install

EXPOSE 8080
CMD ["bun", "src/index.ts"]

Step 9: Install dependencies and verify

cd server && bun install && bun run typecheck

Step 10: Start dev server and test health endpoint

cd server && bun run dev
# In another terminal:
curl http://localhost:8080/health
# Expected: {"status":"ok"}

Step 11: Commit

git add server
git commit -m "feat: scaffold Hono server with auth and health check"

Task 3: Extended Database Schema

Files:

  • Modify: web/src/lib/server/db/schema.ts (add new tables)
  • Modify: web/src/lib/server/auth.ts (add apiKey plugin)
  • Modify: web/src/lib/auth-client.ts (add apiKey client plugin)

Step 1: Add apiKey plugin to Better Auth server config

In web/src/lib/server/auth.ts, add the apiKey plugin:

import { betterAuth } from 'better-auth';
import { sveltekitCookies } from 'better-auth/svelte-kit';
import { apiKey } from 'better-auth/plugins';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { db } from './db';
import { getRequestEvent } from '$app/server';

export const auth = betterAuth({
  database: drizzleAdapter(db, {
    provider: 'pg'
  }),
  plugins: [sveltekitCookies(getRequestEvent), apiKey()],
  emailAndPassword: {
    enabled: true
  }
});

Step 2: Add apiKey client plugin

In web/src/lib/auth-client.ts:

import { createAuthClient } from 'better-auth/svelte';
import { apiKeyClient } from 'better-auth/client/plugins';

export const authClient = createAuthClient({
  baseURL: 'http://localhost:5173',
  plugins: [apiKeyClient()]
});

Step 3: Add new tables to schema.ts

Append to web/src/lib/server/db/schema.ts:

import { pgTable, text, timestamp, boolean, integer, jsonb } from 'drizzle-orm/pg-core';

// ... existing user, session, account, verification tables stay unchanged ...

export const llmConfig = pgTable('llm_config', {
  id: text('id').primaryKey(),
  userId: text('user_id')
    .notNull()
    .references(() => user.id, { onDelete: 'cascade' }),
  provider: text('provider').notNull(), // openai | groq | ollama | bedrock | openrouter
  apiKey: text('api_key').notNull(), // encrypted
  model: text('model'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at')
    .defaultNow()
    .$onUpdate(() => new Date())
    .notNull()
});

export const device = pgTable('device', {
  id: text('id').primaryKey(),
  userId: text('user_id')
    .notNull()
    .references(() => user.id, { onDelete: 'cascade' }),
  name: text('name').notNull(),
  lastSeen: timestamp('last_seen'),
  status: text('status').notNull().default('offline'), // online | offline
  deviceInfo: jsonb('device_info'), // { model, androidVersion, screenWidth, screenHeight }
  createdAt: timestamp('created_at').defaultNow().notNull()
});

export const agentSession = pgTable('agent_session', {
  id: text('id').primaryKey(),
  userId: text('user_id')
    .notNull()
    .references(() => user.id, { onDelete: 'cascade' }),
  deviceId: text('device_id')
    .notNull()
    .references(() => device.id, { onDelete: 'cascade' }),
  goal: text('goal').notNull(),
  status: text('status').notNull().default('running'), // running | completed | failed | cancelled
  stepsUsed: integer('steps_used').default(0),
  startedAt: timestamp('started_at').defaultNow().notNull(),
  completedAt: timestamp('completed_at')
});

export const agentStep = pgTable('agent_step', {
  id: text('id').primaryKey(),
  sessionId: text('session_id')
    .notNull()
    .references(() => agentSession.id, { onDelete: 'cascade' }),
  stepNumber: integer('step_number').notNull(),
  screenHash: text('screen_hash'),
  action: jsonb('action'),
  reasoning: text('reasoning'),
  result: text('result'),
  timestamp: timestamp('timestamp').defaultNow().notNull()
});

Step 4: Generate and run migration

cd web && bun run db:generate && bun run db:push

Step 5: Verify Better Auth apiKey table was created

Better Auth auto-manages its api_key table. Check with:

cd web && bun run db:studio

Verify tables exist: user, session, account, verification, api_key, llm_config, device, agent_session, agent_step.

Step 6: Commit

git add web/src/lib/server/db/schema.ts web/src/lib/server/auth.ts web/src/lib/auth-client.ts web/drizzle/
git commit -m "feat: add apiKey plugin and new schema tables"

Task 4: Hono WebSocket Handlers

Files:

  • Create: server/src/ws/sessions.ts
  • Create: server/src/ws/device.ts
  • Create: server/src/ws/dashboard.ts
  • Modify: server/src/index.ts (wire up WebSocket upgrade with path routing)

Step 1: Create sessions.ts (in-memory session manager)

// server/src/ws/sessions.ts

import type { ServerWebSocket } from "bun";
import type { DeviceInfo } from "@droidclaw/shared";

export interface ConnectedDevice {
  deviceId: string;
  userId: string;
  ws: ServerWebSocket<WebSocketData>;
  deviceInfo?: DeviceInfo;
  connectedAt: Date;
}

export interface DashboardSubscriber {
  userId: string;
  ws: ServerWebSocket<WebSocketData>;
}

export interface WebSocketData {
  path: string; // "/ws/device" or "/ws/dashboard"
  userId?: string;
  deviceId?: string;
  authenticated: boolean;
}

// request/response tracking for command-response pattern
export interface PendingRequest {
  resolve: (value: unknown) => void;
  reject: (reason: Error) => void;
  timer: ReturnType<typeof setTimeout>;
}

class SessionManager {
  // deviceId -> ConnectedDevice
  devices = new Map<string, ConnectedDevice>();
  // userId -> deviceId[] (one user can have multiple devices)
  userDevices = new Map<string, Set<string>>();
  // userId -> DashboardSubscriber[]
  dashboardSubscribers = new Map<string, DashboardSubscriber[]>();
  // requestId -> PendingRequest (for command-response pattern)
  pendingRequests = new Map<string, PendingRequest>();

  addDevice(device: ConnectedDevice) {
    this.devices.set(device.deviceId, device);
    const userDevs = this.userDevices.get(device.userId) ?? new Set();
    userDevs.add(device.deviceId);
    this.userDevices.set(device.userId, userDevs);
  }

  removeDevice(deviceId: string) {
    const device = this.devices.get(deviceId);
    if (device) {
      this.devices.delete(deviceId);
      const userDevs = this.userDevices.get(device.userId);
      if (userDevs) {
        userDevs.delete(deviceId);
        if (userDevs.size === 0) this.userDevices.delete(device.userId);
      }
    }
  }

  getDevice(deviceId: string): ConnectedDevice | undefined {
    return this.devices.get(deviceId);
  }

  getDevicesForUser(userId: string): ConnectedDevice[] {
    const deviceIds = this.userDevices.get(userId);
    if (!deviceIds) return [];
    return [...deviceIds]
      .map((id) => this.devices.get(id))
      .filter((d): d is ConnectedDevice => d !== undefined);
  }

  addDashboardSubscriber(sub: DashboardSubscriber) {
    const subs = this.dashboardSubscribers.get(sub.userId) ?? [];
    subs.push(sub);
    this.dashboardSubscribers.set(sub.userId, subs);
  }

  removeDashboardSubscriber(ws: ServerWebSocket<WebSocketData>) {
    for (const [userId, subs] of this.dashboardSubscribers) {
      const filtered = subs.filter((s) => s.ws !== ws);
      if (filtered.length === 0) {
        this.dashboardSubscribers.delete(userId);
      } else {
        this.dashboardSubscribers.set(userId, filtered);
      }
    }
  }

  // send message to all dashboard subscribers for a user
  notifyDashboard(userId: string, message: object) {
    const subs = this.dashboardSubscribers.get(userId);
    if (!subs) return;
    const data = JSON.stringify(message);
    for (const sub of subs) {
      sub.ws.send(data);
    }
  }

  // send command to device, return promise that resolves when device responds
  sendCommand(deviceId: string, command: object, timeout = 15_000): Promise<unknown> {
    const device = this.devices.get(deviceId);
    if (!device) return Promise.reject(new Error("device not connected"));

    const requestId = crypto.randomUUID();
    const commandWithId = { ...command, requestId };

    return new Promise((resolve, reject) => {
      const timer = setTimeout(() => {
        this.pendingRequests.delete(requestId);
        reject(new Error(`command timeout: ${JSON.stringify(command)}`));
      }, timeout);

      this.pendingRequests.set(requestId, { resolve, reject, timer });
      device.ws.send(JSON.stringify(commandWithId));
    });
  }

  // resolve a pending request (called when device sends a response)
  resolveRequest(requestId: string, data: unknown) {
    const pending = this.pendingRequests.get(requestId);
    if (pending) {
      clearTimeout(pending.timer);
      this.pendingRequests.delete(requestId);
      pending.resolve(data);
    }
  }
}

export const sessions = new SessionManager();

Step 2: Create device.ts (device WebSocket handler)

// server/src/ws/device.ts

import type { ServerWebSocket } from "bun";
import { auth } from "../auth.js";
import { sessions, type WebSocketData } from "./sessions.js";
import type { DeviceMessage } from "@droidclaw/shared";

export async function handleDeviceMessage(
  ws: ServerWebSocket<WebSocketData>,
  raw: string
) {
  let msg: DeviceMessage;
  try {
    msg = JSON.parse(raw);
  } catch {
    ws.send(JSON.stringify({ type: "error", message: "invalid JSON" }));
    return;
  }

  // handle auth handshake
  if (msg.type === "auth") {
    try {
      const result = await auth.api.verifyApiKey({
        body: { key: msg.apiKey },
      });

      if (!result || !result.valid || !result.key) {
        ws.send(JSON.stringify({ type: "auth_error", message: "invalid API key" }));
        ws.close();
        return;
      }

      const deviceId = crypto.randomUUID();
      ws.data.userId = result.key.userId;
      ws.data.deviceId = deviceId;
      ws.data.authenticated = true;

      sessions.addDevice({
        deviceId,
        userId: result.key.userId,
        ws,
        deviceInfo: msg.deviceInfo,
        connectedAt: new Date(),
      });

      ws.send(JSON.stringify({ type: "auth_ok", deviceId }));

      // notify dashboard subscribers
      sessions.notifyDashboard(result.key.userId, {
        type: "device_online",
        deviceId,
        name: msg.deviceInfo?.model ?? "Unknown Device",
      });

      console.log(`Device ${deviceId} connected for user ${result.key.userId}`);
    } catch (e) {
      ws.send(
        JSON.stringify({
          type: "auth_error",
          message: e instanceof Error ? e.message : "auth failed",
        })
      );
      ws.close();
    }
    return;
  }

  // all other messages require authentication
  if (!ws.data.authenticated) {
    ws.send(JSON.stringify({ type: "auth_error", message: "not authenticated" }));
    ws.close();
    return;
  }

  switch (msg.type) {
    case "screen":
    case "result":
      // resolve the pending command request
      sessions.resolveRequest(msg.requestId, msg);
      break;

    case "goal":
      // device-initiated goal — will be handled by agent loop (Task 6)
      // for now, acknowledge
      console.log(`Goal from device ${ws.data.deviceId}: ${msg.text}`);
      break;

    case "pong":
      // heartbeat response — device is alive
      break;
  }
}

export function handleDeviceClose(ws: ServerWebSocket<WebSocketData>) {
  if (ws.data.deviceId && ws.data.userId) {
    sessions.removeDevice(ws.data.deviceId);
    sessions.notifyDashboard(ws.data.userId, {
      type: "device_offline",
      deviceId: ws.data.deviceId,
    });
    console.log(`Device ${ws.data.deviceId} disconnected`);
  }
}

Step 3: Create dashboard.ts (dashboard WebSocket handler)

// server/src/ws/dashboard.ts

import type { ServerWebSocket } from "bun";
import { auth } from "../auth.js";
import { sessions, type WebSocketData } from "./sessions.js";

export async function handleDashboardMessage(
  ws: ServerWebSocket<WebSocketData>,
  raw: string
) {
  let msg: { type: string; [key: string]: unknown };
  try {
    msg = JSON.parse(raw);
  } catch {
    ws.send(JSON.stringify({ type: "error", message: "invalid JSON" }));
    return;
  }

  // auth via session token (sent as first message)
  if (msg.type === "auth") {
    try {
      const token = msg.token as string;
      const session = await auth.api.getSession({
        headers: new Headers({ Authorization: `Bearer ${token}` }),
      });

      if (!session) {
        ws.send(JSON.stringify({ type: "auth_error", message: "invalid session" }));
        ws.close();
        return;
      }

      ws.data.userId = session.user.id;
      ws.data.authenticated = true;

      sessions.addDashboardSubscriber({
        userId: session.user.id,
        ws,
      });

      ws.send(JSON.stringify({ type: "auth_ok" }));

      // send current device list
      const devices = sessions.getDevicesForUser(session.user.id);
      for (const device of devices) {
        ws.send(
          JSON.stringify({
            type: "device_online",
            deviceId: device.deviceId,
            name: device.deviceInfo?.model ?? "Unknown Device",
          })
        );
      }
    } catch {
      ws.send(JSON.stringify({ type: "auth_error", message: "auth failed" }));
      ws.close();
    }
    return;
  }

  if (!ws.data.authenticated) {
    ws.send(JSON.stringify({ type: "auth_error", message: "not authenticated" }));
    return;
  }

  // dashboard messages handled here (e.g., goal submission via WebSocket)
  // REST endpoint POST /goals is the primary way — this is a secondary path
}

export function handleDashboardClose(ws: ServerWebSocket<WebSocketData>) {
  sessions.removeDashboardSubscriber(ws);
}

Step 4: Update index.ts with WebSocket upgrade routing

Replace the placeholder websocket handlers in server/src/index.ts:

// server/src/index.ts

import { Hono } from "hono";
import { cors } from "hono/cors";
import { auth } from "./auth.js";
import { env } from "./env.js";
import { handleDeviceMessage, handleDeviceClose } from "./ws/device.js";
import { handleDashboardMessage, handleDashboardClose } from "./ws/dashboard.js";
import type { WebSocketData } from "./ws/sessions.js";

const app = new Hono();

app.use(
  "*",
  cors({
    origin: env.CORS_ORIGIN,
    allowHeaders: ["Content-Type", "Authorization"],
    allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
    credentials: true,
  })
);

app.on(["POST", "GET"], "/api/auth/*", (c) => {
  return auth.handler(c.req.raw);
});

app.get("/health", (c) => c.json({ status: "ok" }));

const server = Bun.serve<WebSocketData>({
  port: env.PORT,
  fetch(req, server) {
    const url = new URL(req.url);

    // WebSocket upgrade for device connections
    if (url.pathname === "/ws/device") {
      const upgraded = server.upgrade(req, {
        data: { path: "/ws/device", authenticated: false },
      });
      if (upgraded) return undefined;
      return new Response("WebSocket upgrade failed", { status: 400 });
    }

    // WebSocket upgrade for dashboard connections
    if (url.pathname === "/ws/dashboard") {
      const upgraded = server.upgrade(req, {
        data: { path: "/ws/dashboard", authenticated: false },
      });
      if (upgraded) return undefined;
      return new Response("WebSocket upgrade failed", { status: 400 });
    }

    // all other requests go to Hono
    return app.fetch(req);
  },
  websocket: {
    open(ws) {
      console.log(`WebSocket opened: ${ws.data.path}`);
    },
    message(ws, message) {
      const raw = typeof message === "string" ? message : new TextDecoder().decode(message);
      if (ws.data.path === "/ws/device") {
        handleDeviceMessage(ws, raw);
      } else if (ws.data.path === "/ws/dashboard") {
        handleDashboardMessage(ws, raw);
      }
    },
    close(ws) {
      if (ws.data.path === "/ws/device") {
        handleDeviceClose(ws);
      } else if (ws.data.path === "/ws/dashboard") {
        handleDashboardClose(ws);
      }
    },
  },
});

console.log(`Server running on port ${server.port}`);

Step 5: Verify typecheck

cd server && bun run typecheck

Step 6: Commit

git add server/src/ws/ server/src/index.ts
git commit -m "feat: add WebSocket handlers for device and dashboard connections"

Task 5: Hono REST Routes

Files:

  • Create: server/src/routes/devices.ts
  • Create: server/src/routes/goals.ts
  • Create: server/src/routes/health.ts
  • Modify: server/src/index.ts (mount routes)

Step 1: Create session middleware for REST routes

// server/src/middleware/auth.ts

import type { Context, Next } from "hono";
import { auth } from "../auth.js";

export async function sessionMiddleware(c: Context, next: Next) {
  const session = await auth.api.getSession({
    headers: c.req.raw.headers,
  });

  if (!session) {
    return c.json({ error: "unauthorized" }, 401);
  }

  c.set("user", session.user);
  c.set("session", session.session);
  await next();
}

Step 2: Create devices route

// server/src/routes/devices.ts

import { Hono } from "hono";
import { sessionMiddleware } from "../middleware/auth.js";
import { sessions } from "../ws/sessions.js";

const devices = new Hono();

devices.use("*", sessionMiddleware);

// list connected devices for the authenticated user
devices.get("/", (c) => {
  const user = c.get("user");
  const userDevices = sessions.getDevicesForUser(user.id);

  return c.json(
    userDevices.map((d) => ({
      deviceId: d.deviceId,
      name: d.deviceInfo?.model ?? "Unknown Device",
      deviceInfo: d.deviceInfo,
      connectedAt: d.connectedAt.toISOString(),
    }))
  );
});

export { devices };

Step 3: Create goals route

// server/src/routes/goals.ts

import { Hono } from "hono";
import { sessionMiddleware } from "../middleware/auth.js";
import { sessions } from "../ws/sessions.js";

const goals = new Hono();

goals.use("*", sessionMiddleware);

// submit a goal for a connected device
goals.post("/", async (c) => {
  const user = c.get("user");
  const body = await c.req.json<{ deviceId: string; goal: string }>();

  if (!body.deviceId || !body.goal) {
    return c.json({ error: "deviceId and goal are required" }, 400);
  }

  const device = sessions.getDevice(body.deviceId);
  if (!device) {
    return c.json({ error: "device not connected" }, 404);
  }

  if (device.userId !== user.id) {
    return c.json({ error: "device does not belong to you" }, 403);
  }

  // TODO (Task 6): start agent loop for this device+goal
  // For now, acknowledge the goal
  const sessionId = crypto.randomUUID();

  return c.json({
    sessionId,
    deviceId: body.deviceId,
    goal: body.goal,
    status: "queued",
  });
});

export { goals };

Step 4: Extract health route

// server/src/routes/health.ts

import { Hono } from "hono";
import { sessions } from "../ws/sessions.js";

const health = new Hono();

health.get("/", (c) => {
  return c.json({
    status: "ok",
    connectedDevices: sessions.devices.size,
  });
});

export { health };

Step 5: Mount routes in index.ts

Add to server/src/index.ts after the CORS middleware, replacing the inline health check:

import { devices } from "./routes/devices.js";
import { goals } from "./routes/goals.js";
import { health } from "./routes/health.js";

// ... after CORS and auth handler ...

app.route("/devices", devices);
app.route("/goals", goals);
app.route("/health", health);

Remove the old inline app.get("/health", ...).

Step 6: Verify typecheck and test

cd server && bun run typecheck

Step 7: Commit

git add server/src/routes/ server/src/middleware/ server/src/index.ts
git commit -m "feat: add REST routes for devices, goals, and health"

Task 6: Agent Loop (Server-Side)

Files:

  • Create: server/src/agent/loop.ts
  • Create: server/src/agent/llm.ts
  • Create: server/src/agent/stuck.ts
  • Modify: server/src/routes/goals.ts (wire up agent loop)
  • Modify: server/src/ws/device.ts (handle device-initiated goals)

This is the biggest task. It adapts the existing src/kernel.ts logic to work over WebSocket instead of ADB.

Step 1: Create llm.ts

Adapt src/llm-providers.ts — same LLM provider factory, but reads config from the user's llm_config DB row instead of env vars.

// server/src/agent/llm.ts

// This file adapts src/llm-providers.ts to work with per-user LLM config.
// The SYSTEM_PROMPT, provider factory, and response parsing all come from
// the existing codebase. Key differences:
//   - Config comes from DB (llm_config table) not env vars
//   - Same LLMProvider interface
//   - Same parseJsonResponse() logic

// Import the SYSTEM_PROMPT and provider logic from existing src/
// OR copy and adapt the relevant portions.
// The exact approach depends on whether we want to share code via
// packages/shared or duplicate for server independence.

// For v1: duplicate the SYSTEM_PROMPT and provider factory here.
// The prompt is ~200 lines and changes rarely. Duplication is acceptable
// for deployment independence (server deploys without src/).

export interface LLMConfig {
  provider: string; // openai | groq | ollama | bedrock | openrouter
  apiKey: string;
  model?: string;
}

export interface LLMProvider {
  getAction(
    systemPrompt: string,
    userPrompt: string,
    imageBase64?: string
  ): Promise<string>;
}

export function getLlmProvider(config: LLMConfig): LLMProvider {
  // Adapt from src/llm-providers.ts
  // Each provider uses config.apiKey and config.model
  // instead of reading from process.env
  throw new Error("TODO: adapt from src/llm-providers.ts");
}

export function parseJsonResponse(raw: string): Record<string, unknown> | null {
  // Same logic as src/llm-providers.ts parseJsonResponse()
  // Handle clean JSON and markdown-wrapped code blocks
  const cleaned = raw.replace(/```(?:json)?\s*/g, "").replace(/```/g, "").trim();
  try {
    return JSON.parse(cleaned);
  } catch {
    return null;
  }
}

Note for implementer: Copy the SYSTEM_PROMPT, provider implementations (OpenAI, Groq, etc.), and parseJsonResponse from src/llm-providers.ts. Adapt each provider constructor to accept LLMConfig instead of reading env vars. The core logic is identical.

Step 2: Create stuck.ts

// server/src/agent/stuck.ts

// Adapted from kernel.ts stuck-loop detection.
// Same algorithm: track recent actions in a sliding window,
// detect repetition, inject recovery hints.

export interface StuckDetector {
  recordAction(action: string, screenHash: string): void;
  isStuck(): boolean;
  getRecoveryHint(): string;
  reset(): void;
}

export function createStuckDetector(windowSize: number = 5): StuckDetector {
  const recentActions: string[] = [];
  const recentHashes: string[] = [];

  return {
    recordAction(action: string, screenHash: string) {
      recentActions.push(action);
      recentHashes.push(screenHash);
      if (recentActions.length > windowSize) recentActions.shift();
      if (recentHashes.length > windowSize) recentHashes.shift();
    },

    isStuck(): boolean {
      if (recentActions.length < 3) return false;
      // all recent actions are the same
      const allSame = recentActions.every((a) => a === recentActions[0]);
      // all recent screen hashes are the same
      const allSameHash = recentHashes.every((h) => h === recentHashes[0]);
      return allSame || allSameHash;
    },

    getRecoveryHint(): string {
      return (
        "STUCK DETECTED: You have been repeating the same action or seeing the same screen. " +
        "Try a completely different approach: scroll to find new elements, go back, " +
        "use the home button, or try a different app."
      );
    },

    reset() {
      recentActions.length = 0;
      recentHashes.length = 0;
    },
  };
}

Step 3: Create loop.ts (main agent loop)

// server/src/agent/loop.ts

import { sessions } from "../ws/sessions.js";
import { getLlmProvider, parseJsonResponse, type LLMConfig } from "./llm.js";
import { createStuckDetector } from "./stuck.js";
import type { UIElement, ScreenState, ActionDecision } from "@droidclaw/shared";

export interface AgentLoopOptions {
  deviceId: string;
  userId: string;
  goal: string;
  llmConfig: LLMConfig;
  maxSteps?: number;
  onStep?: (step: AgentStep) => void;
  onComplete?: (result: AgentResult) => void;
}

export interface AgentStep {
  stepNumber: number;
  action: ActionDecision;
  reasoning: string;
  screenHash: string;
}

export interface AgentResult {
  success: boolean;
  stepsUsed: number;
  sessionId: string;
}

function computeScreenHash(elements: UIElement[]): string {
  const parts = elements.map(
    (e) => `${e.id}|${e.text}|${e.center[0]},${e.center[1]}|${e.enabled}|${e.checked}`
  );
  return parts.join(";");
}

function actionToCommand(action: ActionDecision): object {
  switch (action.action) {
    case "tap":
      return { type: "tap", x: action.coordinates?.[0], y: action.coordinates?.[1] };
    case "type":
      return { type: "type", text: action.text };
    case "enter":
      return { type: "enter" };
    case "back":
      return { type: "back" };
    case "home":
      return { type: "home" };
    case "swipe":
    case "scroll":
      return { type: "swipe", x1: action.coordinates?.[0], y1: action.coordinates?.[1], x2: 540, y2: 400 };
    case "longpress":
      return { type: "longpress", x: action.coordinates?.[0], y: action.coordinates?.[1] };
    case "launch":
      return { type: "launch", packageName: action.package };
    case "clear":
      return { type: "clear" };
    case "clipboard_set":
      return { type: "clipboard_set", text: action.text };
    case "clipboard_get":
      return { type: "clipboard_get" };
    case "paste":
      return { type: "paste" };
    case "open_url":
      return { type: "open_url", url: action.url };
    case "switch_app":
      return { type: "switch_app", packageName: action.package };
    case "notifications":
      return { type: "notifications" };
    case "keyevent":
      return { type: "keyevent", code: action.code };
    case "open_settings":
      return { type: "open_settings" };
    case "wait":
      return { type: "wait", duration: 2000 };
    case "done":
      return { type: "done" };
    default:
      return { type: action.action };
  }
}

export async function runAgentLoop(options: AgentLoopOptions): Promise<AgentResult> {
  const {
    deviceId,
    userId,
    goal,
    llmConfig,
    maxSteps = 30,
    onStep,
    onComplete,
  } = options;

  const sessionId = crypto.randomUUID();
  const llm = getLlmProvider(llmConfig);
  const stuck = createStuckDetector();
  let lastScreenHash = "";

  // notify dashboard
  sessions.notifyDashboard(userId, {
    type: "goal_started",
    sessionId,
    goal,
    deviceId,
  });

  let stepsUsed = 0;
  let success = false;

  try {
    for (let step = 0; step < maxSteps; step++) {
      stepsUsed = step + 1;

      // 1. Get screen state from device
      const screenResponse = (await sessions.sendCommand(deviceId, {
        type: "get_screen",
      })) as ScreenState & { type: string; requestId: string };

      const elements = screenResponse.elements ?? [];
      const screenHash = computeScreenHash(elements);
      const screenshot = screenResponse.screenshot;

      // 2. Build prompt
      let userPrompt = `GOAL: ${goal}\n\nSTEP: ${step + 1}/${maxSteps}\n\n`;
      userPrompt += `SCREEN ELEMENTS:\n${JSON.stringify(elements, null, 2)}\n\n`;

      if (screenHash === lastScreenHash) {
        userPrompt += "NOTE: Screen has not changed since last action.\n\n";
      }

      if (stuck.isStuck()) {
        userPrompt += stuck.getRecoveryHint() + "\n\n";
      }

      lastScreenHash = screenHash;

      // 3. Call LLM
      // TODO: use the actual SYSTEM_PROMPT from llm.ts once adapted
      const rawResponse = await llm.getAction(
        "You are a phone automation agent...", // placeholder
        userPrompt,
        elements.length < 3 ? screenshot : undefined
      );

      // 4. Parse response
      const parsed = parseJsonResponse(rawResponse);
      if (!parsed || !parsed.action) {
        stuck.recordAction("parse_error", screenHash);
        continue;
      }

      const action = parsed as unknown as ActionDecision;
      stuck.recordAction(action.action, screenHash);

      // 5. Check for "done"
      if (action.action === "done") {
        success = true;
        break;
      }

      // 6. Report step to dashboard
      const stepData: AgentStep = {
        stepNumber: step + 1,
        action,
        reasoning: action.reason ?? "",
        screenHash,
      };
      onStep?.(stepData);
      sessions.notifyDashboard(userId, {
        type: "step",
        sessionId,
        step: step + 1,
        action,
        reasoning: action.reason ?? "",
        screenHash,
      });

      // 7. Execute action on device
      const command = actionToCommand(action);
      await sessions.sendCommand(deviceId, command);

      // 8. Brief pause between steps
      await new Promise((r) => setTimeout(r, 500));
    }
  } catch (error) {
    console.error(`Agent loop error: ${error}`);
  }

  const result: AgentResult = { success, stepsUsed, sessionId };

  // notify dashboard
  sessions.notifyDashboard(userId, {
    type: "goal_completed",
    sessionId,
    success,
    stepsUsed,
  });

  onComplete?.(result);
  return result;
}

Step 4: Wire up agent loop in goals route

Update server/src/routes/goals.ts to start the agent loop:

// Replace the TODO in goals.ts POST handler:

import { runAgentLoop } from "../agent/loop.js";
import { db } from "../db.js";

// Inside the POST handler, after validation:

// fetch user's LLM config from DB
// TODO: query llm_config table for this user
// For now, return error if not configured
const llmConfig = { provider: "groq", apiKey: "TODO", model: "TODO" };

const sessionId = crypto.randomUUID();

// start agent loop in background (don't await — it runs async)
runAgentLoop({
  deviceId: body.deviceId,
  userId: user.id,
  goal: body.goal,
  llmConfig,
}).catch((err) => console.error("Agent loop failed:", err));

return c.json({
  sessionId,
  deviceId: body.deviceId,
  goal: body.goal,
  status: "running",
});

Step 5: Verify typecheck

cd server && bun run typecheck

Step 6: Commit

git add server/src/agent/
git commit -m "feat: add agent loop with LLM integration and stuck detection"

Task 7: Switch SvelteKit to Node Adapter

Files:

  • Modify: web/package.json (swap adapter)
  • Modify: web/svelte.config.js (use node adapter)

Step 1: Install node adapter, remove cloudflare adapter

cd web && bun remove @sveltejs/adapter-cloudflare && bun add -D @sveltejs/adapter-node

Step 2: Update svelte.config.js

import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';

/** @type {import('@sveltejs/kit').Config} */
const config = {
  preprocess: vitePreprocess(),
  kit: {
    experimental: {
      remoteFunctions: true
    },
    adapter: adapter(),
    alias: {
      '@/*': './src/lib/*'
    }
  },
  compilerOptions: {
    experimental: {
      async: true
    }
  }
};

export default config;

Step 3: Verify build

cd web && bun run build

Step 4: Commit

git add web/package.json web/svelte.config.js web/bun.lock
git commit -m "feat: switch SvelteKit from Cloudflare to node adapter"

Task 8: Dashboard Layout & Navigation

Files:

  • Modify: web/src/routes/+layout.svelte (add nav)
  • Create: web/src/routes/+layout.server.ts (load session)
  • Modify: web/src/routes/+page.svelte (redirect logic)
  • Create: web/src/routes/dashboard/+layout.svelte (dashboard shell)
  • Create: web/src/routes/dashboard/+layout.server.ts (auth guard)
  • Create: web/src/routes/dashboard/+page.svelte (overview)

Step 1: Create root layout.server.ts

// web/src/routes/+layout.server.ts

import type { LayoutServerLoad } from './$types';

export const load: LayoutServerLoad = async ({ locals }) => {
  return {
    user: locals.user ?? null
  };
};

Step 2: Update root +page.svelte (redirect)

<!-- web/src/routes/+page.svelte -->
<script lang="ts">
  import { redirect } from '@sveltejs/kit';
  import { getUser } from '$lib/api/auth.remote';

  // redirect based on auth state
  try {
    await getUser();
    redirect(307, '/dashboard');
  } catch {
    redirect(307, '/login');
  }
</script>

Step 3: Create dashboard layout.server.ts (auth guard)

// web/src/routes/dashboard/+layout.server.ts

import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';

export const load: LayoutServerLoad = async ({ locals }) => {
  if (!locals.user) {
    redirect(307, '/login');
  }

  return {
    user: locals.user
  };
};

Step 4: Create dashboard +layout.svelte

<!-- web/src/routes/dashboard/+layout.svelte -->
<script lang="ts">
  import { signout } from '$lib/api/auth.remote';

  let { children, data } = $props();
</script>

<div class="flex min-h-screen">
  <aside class="w-64 border-r border-neutral-200 bg-neutral-50 p-6">
    <h1 class="mb-8 text-xl font-bold">DroidClaw</h1>
    <nav class="flex flex-col gap-2">
      <a href="/dashboard" class="rounded px-3 py-2 hover:bg-neutral-200">Overview</a>
      <a href="/dashboard/devices" class="rounded px-3 py-2 hover:bg-neutral-200">Devices</a>
      <a href="/dashboard/api-keys" class="rounded px-3 py-2 hover:bg-neutral-200">API Keys</a>
      <a href="/dashboard/settings" class="rounded px-3 py-2 hover:bg-neutral-200">Settings</a>
    </nav>
    <div class="mt-auto pt-8">
      <p class="mb-2 text-sm text-neutral-500">{data.user.email}</p>
      <form {...signout}>
        <button type="submit" class="text-sm text-neutral-500 hover:text-neutral-800">
          Sign out
        </button>
      </form>
    </div>
  </aside>

  <main class="flex-1 p-8">
    {@render children?.()}
  </main>
</div>

Step 5: Create dashboard overview page

<!-- web/src/routes/dashboard/+page.svelte -->
<script lang="ts">
  let { data } = $props();
</script>

<h2 class="mb-6 text-2xl font-bold">Dashboard</h2>
<p class="text-neutral-600">Welcome back, {data.user.name}.</p>

<div class="mt-8 grid grid-cols-3 gap-6">
  <a
    href="/dashboard/devices"
    class="rounded-lg border border-neutral-200 p-6 hover:border-neutral-400"
  >
    <h3 class="font-semibold">Devices</h3>
    <p class="mt-1 text-sm text-neutral-500">Manage connected phones</p>
  </a>
  <a
    href="/dashboard/api-keys"
    class="rounded-lg border border-neutral-200 p-6 hover:border-neutral-400"
  >
    <h3 class="font-semibold">API Keys</h3>
    <p class="mt-1 text-sm text-neutral-500">Create keys for your devices</p>
  </a>
  <a
    href="/dashboard/settings"
    class="rounded-lg border border-neutral-200 p-6 hover:border-neutral-400"
  >
    <h3 class="font-semibold">Settings</h3>
    <p class="mt-1 text-sm text-neutral-500">Configure LLM provider</p>
  </a>
</div>

Step 6: Verify dev server

cd web && bun run dev

Navigate to http://localhost:5173 — should redirect to /login or /dashboard.

Step 7: Commit

git add web/src/routes/
git commit -m "feat: add dashboard layout with navigation and auth guard"

Task 9: API Keys Page

Files:

  • Create: web/src/lib/api/api-keys.remote.ts
  • Create: web/src/lib/schema/api-keys.ts
  • Create: web/src/routes/dashboard/api-keys/+page.svelte

Step 1: Create Valibot schema

// web/src/lib/schema/api-keys.ts

import { object, string, pipe, minLength } from 'valibot';

export const createKeySchema = object({
  name: pipe(string(), minLength(1))
});

Step 2: Create remote functions

// web/src/lib/api/api-keys.remote.ts

import { form, query, getRequestEvent } from '$app/server';
import { auth } from '$lib/server/auth';
import { createKeySchema } from '$lib/schema/api-keys';

export const listKeys = query(async () => {
  const { locals } = getRequestEvent();
  if (!locals.user) return [];

  const keys = await auth.api.listApiKeys({
    headers: getRequestEvent().request.headers
  });

  return keys ?? [];
});

export const createKey = form(createKeySchema, async (data) => {
  const { request } = getRequestEvent();

  const result = await auth.api.createApiKey({
    body: {
      name: data.name,
      prefix: 'dc',
      expiresIn: undefined, // no expiry by default
      remaining: undefined  // unlimited
    },
    headers: request.headers
  });

  return result;
});

export const deleteKey = form(async (formData: FormData) => {
  const { request } = getRequestEvent();
  const keyId = formData.get('keyId') as string;

  await auth.api.deleteApiKey({
    body: { keyId },
    headers: request.headers
  });
});

Step 3: Create API Keys page

<!-- web/src/routes/dashboard/api-keys/+page.svelte -->
<script lang="ts">
  import { listKeys, createKey, deleteKey } from '$lib/api/api-keys.remote';

  const keys = await listKeys();

  let newKeyValue = $state<string | null>(null);
</script>

<h2 class="mb-6 text-2xl font-bold">API Keys</h2>

<div class="mb-8 max-w-lg">
  <h3 class="mb-3 text-lg font-semibold">Create New Key</h3>
  <form
    {...createKey}
    onsubmit={(e) => {
      // capture the returned key value after submission
    }}
    class="flex gap-3"
  >
    <input
      {...createKey.fields.name.as('text')}
      placeholder="Key name (e.g., Pixel 8)"
      class="flex-1 rounded border border-neutral-300 px-3 py-2"
    />
    {#each createKey.fields.name.issues() ?? [] as issue (issue.message)}
      <p class="text-sm text-red-500">{issue.message}</p>
    {/each}
    <button type="submit" class="rounded bg-neutral-900 px-4 py-2 text-white hover:bg-neutral-700">
      Create
    </button>
  </form>

  {#if newKeyValue}
    <div class="mt-4 rounded border border-yellow-300 bg-yellow-50 p-4">
      <p class="mb-2 text-sm font-semibold">Copy your API key now. It won't be shown again.</p>
      <code class="block break-all rounded bg-neutral-100 p-2 text-sm">{newKeyValue}</code>
    </div>
  {/if}
</div>

<div>
  <h3 class="mb-3 text-lg font-semibold">Your Keys</h3>
  {#if keys.length === 0}
    <p class="text-neutral-500">No API keys yet. Create one to connect your Android device.</p>
  {:else}
    <div class="space-y-3">
      {#each keys as key (key.id)}
        <div class="flex items-center justify-between rounded border border-neutral-200 p-4">
          <div>
            <p class="font-medium">{key.name ?? 'Unnamed Key'}</p>
            <p class="text-sm text-neutral-500">
              {key.prefix}_{'*'.repeat(20)} &middot; Created {new Date(key.createdAt).toLocaleDateString()}
            </p>
          </div>
          <form {...deleteKey}>
            <input type="hidden" name="keyId" value={key.id} />
            <button type="submit" class="text-sm text-red-500 hover:text-red-700">Delete</button>
          </form>
        </div>
      {/each}
    </div>
  {/if}
</div>

Note for implementer: The createKey remote function returns the full key value only on creation. The listKeys response only shows the prefix (hashed key). The page needs to capture the creation response to show the full key once. The exact mechanism depends on how remote functions return data in Svelte 5 async mode — check the existing auth.remote.ts pattern and adapt. You may need to use $effect or a callback to capture the created key value.

Step 4: Verify dev server

cd web && bun run dev

Navigate to /dashboard/api-keys.

Step 5: Commit

git add web/src/lib/api/api-keys.remote.ts web/src/lib/schema/api-keys.ts web/src/routes/dashboard/api-keys/
git commit -m "feat: add API keys management page"

Task 10: Settings Page (LLM Config)

Files:

  • Create: web/src/lib/api/settings.remote.ts
  • Create: web/src/lib/schema/settings.ts
  • Create: web/src/routes/dashboard/settings/+page.svelte

Step 1: Create Valibot schema

// web/src/lib/schema/settings.ts

import { object, string, pipe, minLength, optional } from 'valibot';

export const llmConfigSchema = object({
  provider: pipe(string(), minLength(1)),
  apiKey: pipe(string(), minLength(1)),
  model: optional(string())
});

Step 2: Create remote functions

// web/src/lib/api/settings.remote.ts

import { form, query, getRequestEvent } from '$app/server';
import { db } from '$lib/server/db';
import { llmConfig } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import { llmConfigSchema } from '$lib/schema/settings';

export const getConfig = query(async () => {
  const { locals } = getRequestEvent();
  if (!locals.user) return null;

  const config = await db
    .select()
    .from(llmConfig)
    .where(eq(llmConfig.userId, locals.user.id))
    .limit(1);

  if (config.length === 0) return null;

  // mask the API key for display
  return {
    ...config[0],
    apiKey: config[0].apiKey.slice(0, 8) + '...' + config[0].apiKey.slice(-4)
  };
});

export const updateConfig = form(llmConfigSchema, async (data) => {
  const { locals } = getRequestEvent();
  if (!locals.user) return;

  const existing = await db
    .select()
    .from(llmConfig)
    .where(eq(llmConfig.userId, locals.user.id))
    .limit(1);

  if (existing.length > 0) {
    await db
      .update(llmConfig)
      .set({
        provider: data.provider,
        apiKey: data.apiKey,
        model: data.model ?? null
      })
      .where(eq(llmConfig.userId, locals.user.id));
  } else {
    await db.insert(llmConfig).values({
      id: crypto.randomUUID(),
      userId: locals.user.id,
      provider: data.provider,
      apiKey: data.apiKey,
      model: data.model ?? null
    });
  }
});

Step 3: Create Settings page

<!-- web/src/routes/dashboard/settings/+page.svelte -->
<script lang="ts">
  import { getConfig, updateConfig } from '$lib/api/settings.remote';

  const config = await getConfig();
</script>

<h2 class="mb-6 text-2xl font-bold">Settings</h2>

<div class="max-w-lg">
  <h3 class="mb-4 text-lg font-semibold">LLM Provider</h3>

  <form {...updateConfig} class="space-y-4">
    <label class="block">
      <span class="text-sm font-medium">Provider</span>
      <select
        {...updateConfig.fields.provider.as('text')}
        class="mt-1 block w-full rounded border border-neutral-300 px-3 py-2"
      >
        <option value="openai">OpenAI</option>
        <option value="groq">Groq</option>
        <option value="ollama">Ollama</option>
        <option value="bedrock">AWS Bedrock</option>
        <option value="openrouter">OpenRouter</option>
      </select>
      {#each updateConfig.fields.provider.issues() ?? [] as issue (issue.message)}
        <p class="text-sm text-red-500">{issue.message}</p>
      {/each}
    </label>

    <label class="block">
      <span class="text-sm font-medium">API Key</span>
      <input
        {...updateConfig.fields.apiKey.as('password')}
        placeholder={config?.apiKey ?? 'Enter your API key'}
        class="mt-1 block w-full rounded border border-neutral-300 px-3 py-2"
      />
      {#each updateConfig.fields.apiKey.issues() ?? [] as issue (issue.message)}
        <p class="text-sm text-red-500">{issue.message}</p>
      {/each}
    </label>

    <label class="block">
      <span class="text-sm font-medium">Model (optional)</span>
      <input
        {...updateConfig.fields.model.as('text')}
        placeholder="e.g., gpt-4o, llama-3.3-70b-versatile"
        class="mt-1 block w-full rounded border border-neutral-300 px-3 py-2"
      />
    </label>

    <button type="submit" class="rounded bg-neutral-900 px-4 py-2 text-white hover:bg-neutral-700">
      Save
    </button>
  </form>

  {#if config}
    <p class="mt-4 text-sm text-neutral-500">
      Current: {config.provider} &middot; Key: {config.apiKey}
      {#if config.model}&middot; Model: {config.model}{/if}
    </p>
  {/if}
</div>

Step 4: Verify dev server

cd web && bun run dev

Navigate to /dashboard/settings.

Step 5: Commit

git add web/src/lib/api/settings.remote.ts web/src/lib/schema/settings.ts web/src/routes/dashboard/settings/
git commit -m "feat: add LLM provider settings page"

Task 11: Devices Page

Files:

  • Create: web/src/lib/api/devices.remote.ts
  • Create: web/src/routes/dashboard/devices/+page.svelte
  • Create: web/src/routes/dashboard/devices/[deviceId]/+page.svelte

Step 1: Create remote functions

// web/src/lib/api/devices.remote.ts

import { query, getRequestEvent } from '$app/server';
import { env } from '$env/dynamic/private';

const SERVER_URL = env.SERVER_URL || 'http://localhost:8080';

export const listDevices = query(async () => {
  const { request } = getRequestEvent();

  const res = await fetch(`${SERVER_URL}/devices`, {
    headers: {
      cookie: request.headers.get('cookie') ?? ''
    }
  });

  if (!res.ok) return [];
  return res.json();
});

Note for implementer: The dashboard calls the Hono server's /devices endpoint. In production on Railway, SERVER_URL points to the Hono server's internal URL. The session cookie is forwarded so Hono can verify the user. You may need to adjust the Hono session middleware to accept forwarded cookies from SvelteKit.

Step 2: Create devices list page

<!-- web/src/routes/dashboard/devices/+page.svelte -->
<script lang="ts">
  import { listDevices } from '$lib/api/devices.remote';

  const devices = await listDevices();
</script>

<h2 class="mb-6 text-2xl font-bold">Devices</h2>

{#if devices.length === 0}
  <div class="rounded border border-neutral-200 p-8 text-center">
    <p class="text-neutral-500">No devices connected.</p>
    <p class="mt-2 text-sm text-neutral-400">
      Install the Android app, paste your API key, and your device will appear here.
    </p>
    <a href="/dashboard/api-keys" class="mt-4 inline-block text-sm text-blue-600 hover:underline">
      Create an API key
    </a>
  </div>
{:else}
  <div class="space-y-3">
    {#each devices as device (device.deviceId)}
      <a
        href="/dashboard/devices/{device.deviceId}"
        class="flex items-center justify-between rounded border border-neutral-200 p-4 hover:border-neutral-400"
      >
        <div>
          <p class="font-medium">{device.name}</p>
          <p class="text-sm text-neutral-500">
            Connected {new Date(device.connectedAt).toLocaleString()}
          </p>
        </div>
        <span class="inline-block h-2 w-2 rounded-full bg-green-500"></span>
      </a>
    {/each}
  </div>
{/if}

Step 3: Create device detail page (goal input + live logs)

<!-- web/src/routes/dashboard/devices/[deviceId]/+page.svelte -->
<script lang="ts">
  import { page } from '$app/state';
  import { env } from '$env/dynamic/public';

  const deviceId = page.params.deviceId;
  const SERVER_WS_URL = env.PUBLIC_SERVER_WS_URL || 'ws://localhost:8080';

  let goal = $state('');
  let steps = $state<Array<{ step: number; action: string; reasoning: string }>>([]);
  let status = $state<'idle' | 'running' | 'completed' | 'failed'>('idle');

  async function submitGoal() {
    if (!goal.trim()) return;
    status = 'running';
    steps = [];

    const res = await fetch(`/api/goals`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ deviceId, goal })
    });

    if (!res.ok) {
      status = 'failed';
      return;
    }

    // connect to dashboard WebSocket for live step updates
    // TODO: implement WebSocket connection to Hono server
    // The dashboard WebSocket handler will stream steps for this session
  }
</script>

<h2 class="mb-6 text-2xl font-bold">Device: {deviceId.slice(0, 8)}...</h2>

<div class="max-w-2xl">
  <div class="mb-8">
    <label class="mb-2 block text-sm font-medium">Goal</label>
    <div class="flex gap-3">
      <input
        type="text"
        bind:value={goal}
        placeholder="e.g., Open YouTube and search for lofi beats"
        class="flex-1 rounded border border-neutral-300 px-3 py-2"
        disabled={status === 'running'}
      />
      <button
        onclick={submitGoal}
        disabled={status === 'running'}
        class="rounded bg-neutral-900 px-4 py-2 text-white hover:bg-neutral-700 disabled:opacity-50"
      >
        {status === 'running' ? 'Running...' : 'Run'}
      </button>
    </div>
  </div>

  {#if steps.length > 0}
    <h3 class="mb-3 text-lg font-semibold">Steps</h3>
    <div class="space-y-2">
      {#each steps as step (step.step)}
        <div class="rounded border border-neutral-200 p-3">
          <p class="text-sm font-medium">Step {step.step}: {step.action}</p>
          <p class="text-sm text-neutral-500">{step.reasoning}</p>
        </div>
      {/each}
    </div>
  {/if}

  {#if status === 'completed'}
    <p class="mt-4 text-green-600">Goal completed successfully.</p>
  {:else if status === 'failed'}
    <p class="mt-4 text-red-600">Goal failed.</p>
  {/if}
</div>

Note for implementer: The device detail page needs a SvelteKit API route (/api/goals) that proxies to the Hono server, or it can call the Hono server directly via PUBLIC_SERVER_URL. The live step stream requires a WebSocket connection from the browser to Hono's /ws/dashboard endpoint. Implement the WebSocket connection in a $effect block that connects when the page mounts and disconnects on unmount.

Step 4: Add SERVER_URL and PUBLIC_SERVER_WS_URL to web/.env.example

Append to web/.env.example:

SERVER_URL="http://localhost:8080"
PUBLIC_SERVER_WS_URL="ws://localhost:8080"

Step 5: Commit

git add web/src/lib/api/devices.remote.ts web/src/routes/dashboard/devices/ web/.env.example
git commit -m "feat: add devices page with goal input and step log"

Task 12: Wire Up Goal Proxy API Route

Files:

  • Create: web/src/routes/api/goals/+server.ts

The device detail page needs to POST goals to the Hono server. Create a SvelteKit API route that proxies the request.

Step 1: Create the API route

// web/src/routes/api/goals/+server.ts

import { json, error } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import type { RequestHandler } from './$types';

const SERVER_URL = env.SERVER_URL || 'http://localhost:8080';

export const POST: RequestHandler = async ({ request, locals }) => {
  if (!locals.user) {
    return error(401, 'Unauthorized');
  }

  const body = await request.json();

  const res = await fetch(`${SERVER_URL}/goals`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      cookie: request.headers.get('cookie') ?? ''
    },
    body: JSON.stringify(body)
  });

  if (!res.ok) {
    const err = await res.json().catch(() => ({ error: 'Unknown error' }));
    return error(res.status, err.error ?? 'Failed to submit goal');
  }

  return json(await res.json());
};

Step 2: Commit

git add web/src/routes/api/goals/
git commit -m "feat: add goal proxy API route"

Task 13: Environment & Dockerfiles

Files:

  • Create: web/Dockerfile
  • Modify: server/.env.example (finalize)
  • Modify: web/.env.example (finalize)

Step 1: Create web Dockerfile

FROM oven/bun:1 AS builder

WORKDIR /app
COPY web/package.json web/bun.lock ./
RUN bun install --frozen-lockfile

COPY web/ .
RUN bun run build

FROM oven/bun:1

WORKDIR /app
COPY --from=builder /app/build ./build
COPY --from=builder /app/package.json .
COPY --from=builder /app/node_modules ./node_modules

EXPOSE 3000
ENV PORT=3000
CMD ["bun", "build/index.js"]

Step 2: Finalize server .env.example

DATABASE_URL="postgres://user:password@host:port/db-name"
PORT=8080
CORS_ORIGIN="http://localhost:5173"

Step 3: Finalize web .env.example

DATABASE_URL="postgres://user:password@host:port/db-name"
SERVER_URL="http://localhost:8080"
PUBLIC_SERVER_WS_URL="ws://localhost:8080"

Step 4: Commit

git add web/Dockerfile server/.env.example web/.env.example
git commit -m "feat: add Dockerfiles and finalize env examples"

Task 14: Integration Smoke Test

No new files. Manual verification.

Step 1: Start Postgres (local or Railway)

Ensure DATABASE_URL is set in both web/.env and server/.env.

Step 2: Run migrations

cd web && bun run db:push

Step 3: Start both servers

# terminal 1
cd web && bun run dev

# terminal 2
cd server && bun run dev

Step 4: Manual test flow

  1. Open http://localhost:5173 -> redirects to /login
  2. Sign up with email/password
  3. Redirected to /dashboard
  4. Go to /dashboard/api-keys -> create a key named "Test Device"
  5. Copy the key
  6. Go to /dashboard/settings -> set provider to "groq", enter API key, model
  7. Go to /dashboard/devices -> shows "No devices connected"
  8. Test Hono health: curl http://localhost:8080/health -> {"status":"ok","connectedDevices":0}
  9. Test WebSocket auth (using wscat or similar):
    wscat -c ws://localhost:8080/ws/device
    # send: {"type":"auth","apiKey":"dc_your_key_here"}
    # expect: {"type":"auth_ok","deviceId":"..."}
    
  10. Dashboard devices page should now show the connected device

Step 5: Commit any fixes from smoke test

git add -A && git commit -m "fix: address issues found during integration smoke test"

Future: Android App (Task 15+)

Not building now. The server is ready for Android connections via:

  • ws://server:8080/ws/device — WebSocket endpoint
  • API key auth on handshake
  • Full command protocol defined in @droidclaw/shared

When building the Android app, follow the structure in OPTION1-IMPLEMENTATION.md and the android/ plan in the design doc. The Kotlin data classes should mirror @droidclaw/shared types.