Welcome back, {data.user.name}.
-
-
-
Your Keys
- {#if keys.length === 0}
-
No API keys yet. Create one to connect your Android device.
- {:else}
-
- {#each keys as key (key.id)}
-
-
-
{key.name ?? 'Unnamed Key'}
-
- {key.prefix}_{'*'.repeat(20)} · Created {new Date(key.createdAt).toLocaleDateString()}
-
-
-
-
- {/each}
-
- {/if}
-
-```
-
-> **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**
-
-```bash
-cd web && bun run dev
-```
-
-Navigate to `/dashboard/api-keys`.
-
-**Step 5: Commit**
-
-```bash
-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**
-
-```typescript
-// 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**
-
-```typescript
-// 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**
-
-```svelte
-
-
-
-
-
No devices connected.
-
- Install the Android app, paste your API key, and your device will appear here.
-
-
- Create an API key
-
-
-{:else}
-
-
-
-
-
-
-
-
-
- {#if steps.length > 0}
-
Steps
-
- {#each steps as step (step.step)}
-
-
Step {step.step}: {step.action}
-
{step.reasoning}
-
- {/each}
-
- {/if}
-
- {#if status === 'completed'}
-
Goal completed successfully.
- {:else if status === 'failed'}
-
Goal failed.
- {/if}
-
-```
-
-> **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**
-
-```bash
-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**
-
-```typescript
-// 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**
-
-```bash
-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**
-
-```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**
-
-```bash
-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**
-
-```bash
-cd web && bun run db:push
-```
-
-**Step 3: Start both servers**
-
-```bash
-# 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):
- ```bash
- 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**
-
-```bash
-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.
diff --git a/docs/plans/2026-02-17-option1-web-backend-design.md b/docs/plans/2026-02-17-option1-web-backend-design.md
deleted file mode 100644
index 32636ea..0000000
--- a/docs/plans/2026-02-17-option1-web-backend-design.md
+++ /dev/null
@@ -1,357 +0,0 @@
-# Option 1: Web Dashboard + Backend Design
-
-> Date: 2026-02-17
-> Status: Approved
-> Scope: Web (SvelteKit) + Backend (Hono.js) + Android app plan
-
----
-
-## Decisions
-
-- **Monorepo**: `web/` (SvelteKit dashboard) + `server/` (Hono.js backend) + `android/` (future)
-- **Separate Hono server** for WebSocket + agent loop (independent lifecycle from dashboard)
-- **SvelteKit** with node adapter for dashboard (deploy to Railway)
-- **Multiple API keys** per user with labels (Better Auth apiKey plugin)
-- **LLM config on dashboard only** (BYOK -- user provides their own API keys)
-- **Goals sent from both** web dashboard and Android app
-- **Dashboard v1**: API keys, LLM config, connected devices, goal input, step logs
-- **Server runs the agent loop** (phone is eyes + hands)
-- **Shared Postgres** on Railway (both services connect to same DB)
-- **Build order**: web + server first, Android later
-
----
-
-## Monorepo Structure
-
-```
-droidclaw/
-├── src/ # existing CLI agent (kernel.ts, actions.ts, etc.)
-├── web/ # SvelteKit dashboard (existing, extend)
-├── server/ # Hono.js backend (WebSocket + agent loop)
-├── android/ # Kotlin companion app (future)
-├── packages/shared/ # shared TypeScript types
-├── package.json # root
-└── CLAUDE.md
-```
-
----
-
-## Auth & API Key System
-
-Both apps share the same Postgres DB and the same Better Auth tables.
-
-SvelteKit handles user-facing auth (login, signup, sessions). Hono verifies API keys from Android devices.
-
-### Better Auth Config
-
-Both apps use Better Auth with the `apiKey` plugin. SvelteKit adds `sveltekitCookies`, Hono adds session middleware.
-
-```typescript
-// shared pattern
-plugins: [
- apiKey() // built-in API key plugin
-]
-```
-
-### Flow
-
-1. User signs up/logs in on SvelteKit dashboard (existing)
-2. Dashboard "API Keys" page -- user creates keys with labels (e.g., "Pixel 8", "Work Phone")
-3. Better Auth's apiKey plugin handles create/list/delete
-4. User copies key, pastes into Android app SharedPreferences
-5. Android app connects to Hono server via WebSocket, sends API key in handshake
-6. Hono calls `auth.api.verifyApiKey({ body: { key } })` -- if valid, establishes device session
-7. Dashboard WebSocket connections use session cookies (user already logged in)
-
-### Database Schema
-
-Better Auth manages: `user`, `session`, `account`, `verification`, `api_key`
-
-Additional tables (Drizzle):
-
-```
-llm_config
- - id: text PK
- - userId: text FK -> user.id
- - provider: text (openai | groq | ollama | bedrock | openrouter)
- - apiKey: text (encrypted)
- - model: text
- - createdAt: timestamp
- - updatedAt: timestamp
-
-device
- - id: text PK
- - userId: text FK -> user.id
- - name: text
- - lastSeen: timestamp
- - status: text (online | offline)
- - deviceInfo: jsonb (model, androidVersion, screenWidth, screenHeight)
- - createdAt: timestamp
-
-agent_session
- - id: text PK
- - userId: text FK -> user.id
- - deviceId: text FK -> device.id
- - goal: text
- - status: text (running | completed | failed | cancelled)
- - stepsUsed: integer
- - startedAt: timestamp
- - completedAt: timestamp
-
-agent_step
- - id: text PK
- - sessionId: text FK -> agent_session.id
- - stepNumber: integer
- - screenHash: text
- - action: jsonb
- - reasoning: text
- - result: text
- - timestamp: timestamp
-```
-
----
-
-## Hono Server Architecture (`server/`)
-
-```
-server/
-├── src/
-│ ├── index.ts # Hono app + Bun.serve with WebSocket upgrade
-│ ├── auth.ts # Better Auth instance (same DB, apiKey plugin)
-│ ├── middleware/
-│ │ ├── auth.ts # Session middleware (dashboard WebSocket)
-│ │ └── api-key.ts # API key verification (Android WebSocket)
-│ ├── ws/
-│ │ ├── device.ts # WebSocket handler for Android devices
-│ │ ├── dashboard.ts # WebSocket handler for web dashboard (live logs)
-│ │ └── sessions.ts # In-memory session manager (connected devices + active loops)
-│ ├── agent/
-│ │ ├── loop.ts # Agent loop (adapted from kernel.ts)
-│ │ ├── llm.ts # LLM provider factory (adapted from llm-providers.ts)
-│ │ ├── stuck.ts # Stuck-loop detection
-│ │ └── skills.ts # Multi-step skills (adapted from skills.ts)
-│ ├── routes/
-│ │ ├── devices.ts # GET /devices
-│ │ ├── goals.ts # POST /goals
-│ │ └── health.ts # GET /health
-│ ├── db.ts # Drizzle instance (same Postgres)
-│ └── env.ts # Environment config
-├── package.json
-├── tsconfig.json
-└── Dockerfile
-```
-
-### Key Design Points
-
-1. **Bun.serve() with WebSocket upgrade** -- Hono handles HTTP, Bun native WebSocket handles upgrades. No extra WS library.
-
-2. **Two WebSocket paths:**
- - `/ws/device` -- Android app connects with API key
- - `/ws/dashboard` -- Web dashboard connects with session cookie
-
-3. **sessions.ts** -- In-memory map tracking connected devices, active agent loops, dashboard subscribers.
-
-4. **Agent loop (loop.ts)** -- Adapted from kernel.ts. Same perception/reasoning/action cycle. Sends WebSocket commands instead of ADB calls.
-
-5. **Goal submission:**
- - Dashboard: POST /goals -> starts agent loop -> streams steps via dashboard WebSocket
- - Android: device sends `{ type: "goal", text: "..." }` -> same agent loop
-
----
-
-## SvelteKit Dashboard (`web/`)
-
-Follows existing patterns: remote functions (`$app/server` form/query), Svelte 5 runes, Tailwind v4, Valibot schemas.
-
-### Route Structure
-
-```
-web/src/routes/
-├── +layout.svelte # add nav bar
-├── +layout.server.ts # load session for all pages
-├── +page.svelte # redirect: logged in -> /dashboard, else -> /login
-├── login/+page.svelte # existing
-├── signup/+page.svelte # existing
-├── dashboard/
-│ ├── +layout.svelte # dashboard shell (sidebar nav)
-│ ├── +page.svelte # overview: connected devices, quick goal input
-│ ├── api-keys/
-│ │ └── +page.svelte # list keys, create with label, copy, delete
-│ ├── settings/
-│ │ └── +page.svelte # LLM provider config (provider, API key, model)
-│ └── devices/
-│ ├── +page.svelte # list connected devices with status
-│ └── [deviceId]/
-│ └── +page.svelte # device detail: send goal, live step log
-```
-
-### Remote Functions
-
-```
-web/src/lib/api/
-├── auth.remote.ts # existing (signup, login, signout, getUser)
-├── api-keys.remote.ts # createKey, listKeys, deleteKey (Better Auth client)
-├── settings.remote.ts # getConfig, updateConfig (LLM provider/key)
-├── devices.remote.ts # listDevices (queries Hono server)
-└── goals.remote.ts # submitGoal (POST to Hono server)
-```
-
-Dashboard WebSocket for live step logs connects directly to Hono server from the browser (not through SvelteKit).
-
----
-
-## WebSocket Protocol
-
-### Device -> Server (Android app sends)
-
-```json
-// Handshake
-{ "type": "auth", "apiKey": "dc_xxxxx" }
-
-// Screen tree response
-{ "type": "screen", "requestId": "uuid", "elements": [], "screenshot": "base64?", "packageName": "com.app" }
-
-// Action result
-{ "type": "result", "requestId": "uuid", "success": true, "error": null, "data": null }
-
-// Goal from phone
-{ "type": "goal", "text": "open youtube and search lofi" }
-
-// Heartbeat
-{ "type": "pong" }
-```
-
-### Server -> Device (Hono sends)
-
-```json
-// Auth
-{ "type": "auth_ok", "deviceId": "uuid" }
-{ "type": "auth_error", "message": "invalid key" }
-
-// Commands (all 22 actions)
-{ "type": "get_screen", "requestId": "uuid" }
-{ "type": "tap", "requestId": "uuid", "x": 540, "y": 1200 }
-{ "type": "type", "requestId": "uuid", "text": "lofi beats" }
-{ "type": "swipe", "requestId": "uuid", "x1": 540, "y1": 1600, "x2": 540, "y2": 400 }
-{ "type": "enter", "requestId": "uuid" }
-{ "type": "back", "requestId": "uuid" }
-{ "type": "home", "requestId": "uuid" }
-{ "type": "launch", "requestId": "uuid", "packageName": "com.google.android.youtube" }
-// ... remaining actions follow same pattern
-
-// Heartbeat
-{ "type": "ping" }
-
-// Goal lifecycle
-{ "type": "goal_started", "sessionId": "uuid", "goal": "..." }
-{ "type": "goal_completed", "sessionId": "uuid", "success": true, "stepsUsed": 12 }
-```
-
-### Server -> Dashboard (live step stream)
-
-```json
-// Device status
-{ "type": "device_online", "deviceId": "uuid", "name": "Pixel 8" }
-{ "type": "device_offline", "deviceId": "uuid" }
-
-// Step stream
-{ "type": "step", "sessionId": "uuid", "step": 3, "action": {}, "reasoning": "...", "screenHash": "..." }
-{ "type": "goal_started", "sessionId": "uuid", "goal": "...", "deviceId": "uuid" }
-{ "type": "goal_completed", "sessionId": "uuid", "success": true, "stepsUsed": 12 }
-```
-
----
-
-## Shared Types (`packages/shared/`)
-
-```
-packages/shared/
-├── src/
-│ ├── types.ts # UIElement, Bounds, Point
-│ ├── commands.ts # Command, CommandResult type unions
-│ ├── actions.ts # ActionDecision type (all 22 actions)
-│ └── protocol.ts # WebSocket message types
-├── package.json # name: "@droidclaw/shared"
-└── tsconfig.json
-```
-
-Replaces duplicated types across src/, server/, web/. Android app mirrors in Kotlin via @Serializable data classes.
-
----
-
-## Android App (future, plan only)
-
-```
-android/
-├── app/src/main/kotlin/ai/droidclaw/companion/
-│ ├── DroidClawApp.kt
-│ ├── MainActivity.kt # API key input, setup checklist, status
-│ ├── accessibility/
-│ │ ├── DroidClawAccessibilityService.kt
-│ │ ├── ScreenTreeBuilder.kt
-│ │ └── GestureExecutor.kt
-│ ├── capture/
-│ │ └── ScreenCaptureService.kt
-│ ├── connection/
-│ │ ├── ConnectionService.kt # Foreground service
-│ │ ├── ReliableWebSocket.kt # Reconnect, heartbeat, message queue
-│ │ └── CommandRouter.kt
-│ └── model/
-│ ├── UIElement.kt # Mirrors @droidclaw/shared types
-│ ├── Command.kt
-│ └── DeviceInfo.kt
-├── build.gradle.kts
-└── AndroidManifest.xml
-```
-
-Follows OPTION1-IMPLEMENTATION.md structure. Not building now, but server protocol is designed for it.
-
----
-
-## Deployment (Railway)
-
-| Service | Source | Port | Notes |
-|---|---|---|---|
-| web | `web/` | 3000 | SvelteKit + node adapter |
-| server | `server/` | 8080 | Hono + Bun.serve |
-| postgres | Railway managed | 5432 | Shared by both services |
-
-Both services get the same `DATABASE_URL`. Web calls Hono via Railway internal networking for REST. Browser connects directly to Hono's public URL for WebSocket.
-
----
-
-## Data Flow
-
-```
-USER (browser) HONO SERVER PHONE (Android app)
- | | |
- | signs in (SvelteKit) | |
- | creates API key | |
- | | |
- | | { type: "auth", key: "dc_xxx" }
- | |<------------------------------|
- | | { type: "auth_ok" } |
- | |------------------------------>|
- | | |
- | POST /goals | |
- | "open youtube, search lofi" | |
- |------------------------------>| |
- | | { type: "get_screen" } |
- | |------------------------------>|
- | | |
- | | { type: "screen", elements } |
- | |<------------------------------|
- | | |
- | | LLM: "launch youtube" |
- | | |
- | { type: "step", action } | { type: "launch", pkg } |
- |<------------------------------|------------------------------>|
- | | |
- | | { success: true } |
- | |<------------------------------|
- | | |
- | ... repeat until done ... | |
- | | |
- | { type: "goal_completed" } | { type: "goal_completed" } |
- |<------------------------------|------------------------------>|
-```