diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 2ebad08e..1f07f86a 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -124,7 +124,13 @@ Plans:
3. Token cost for Puter responses appears in the cost tracking view, attributed correctly per conversation
4. A user with Hermes, Claude Code, or OpenClaw already installed sees those tools pre-filled in the provider configuration step with no manual entry
5. A user clicking "Sign in with Google" for Gemini completes PKCE OAuth and gets a Gemini-backed chat response; the UI displays a policy-risk note that Google OAuth may trigger abuse detection
-**Plans**: TBD
+**Plans**: 4 plans
+
+Plans:
+- [ ] 31-01-PLAN.md — Puter proxy service, routes, unit tests, and app.ts wiring
+- [ ] 31-02-PLAN.md — Google OAuth PKCE service, routes, API key storage route
+- [ ] 31-03-PLAN.md — Provider Selection UI step, PuterAuthButton, GoogleOAuthButton, ApiKeyEntryForm, 4-step wizard wiring
+- [ ] 31-04-PLAN.md — Google OAuth claim endpoint, human verification of full onboarding flow
**UI hint**: yes
### Phase 32: Multi-Step Onboarding Wizard
@@ -216,7 +222,7 @@ All 21 v1.5 requirements are mapped to exactly one phase. No orphans.
| 28. Ollama Integration & Agent Surface | v1.4 | 3/3 | Complete | 2026-04-02 |
| 29. Default Provider & End-to-End | v1.4 | 2/2 | Complete | 2026-04-02 |
| 30. Hardware Detection + Mode Selection | v1.5 | 2/2 | Complete | 2026-04-03 |
-| 31. Puter.js Zero-Config Cloud | v1.5 | 0/TBD | Not started | - |
+| 31. Puter.js Zero-Config Cloud | v1.5 | 0/4 | Not started | - |
| 32. Multi-Step Onboarding Wizard | v1.5 | 0/TBD | Not started | - |
| 33. Persistent Memory + Personal Assistant Mode | v1.5 | 0/TBD | Not started | - |
| 34. Voice | v1.5 | 0/TBD | Not started | - |
diff --git a/.planning/phases/31-puter.js-zero-config-cloud/31-01-PLAN.md b/.planning/phases/31-puter.js-zero-config-cloud/31-01-PLAN.md
new file mode 100644
index 00000000..1d1379f0
--- /dev/null
+++ b/.planning/phases/31-puter.js-zero-config-cloud/31-01-PLAN.md
@@ -0,0 +1,253 @@
+---
+phase: 31-puter.js-zero-config-cloud
+plan: "01"
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - server/src/services/puter-proxy.ts
+ - server/src/routes/puter-proxy.ts
+ - server/src/__tests__/31-puter-proxy.test.ts
+ - server/src/app.ts
+autonomous: true
+requirements: [CLOUD-01, CLOUD-02]
+
+must_haves:
+ truths:
+ - "Puter auth token can be stored server-side via secretService and retrieved for API calls"
+ - "POST /api/puter-proxy/chat relays messages to Puter OpenAI-compat endpoint with correct Bearer header"
+ - "POST /api/puter-proxy/chat streams SSE tokens back to the client"
+ - "Cost events are recorded after every Puter chat completion with provider=puter and billingType=subscription_included"
+ - "POST /api/puter-proxy/token stores (or rotates) the Puter auth token idempotently"
+ artifacts:
+ - path: "server/src/services/puter-proxy.ts"
+ provides: "puterProxyService with resolveToken, chatStream, storeToken methods"
+ exports: ["puterProxyService"]
+ - path: "server/src/routes/puter-proxy.ts"
+ provides: "Express routes for POST /api/puter-proxy/chat and POST /api/puter-proxy/token"
+ exports: ["puterProxyRoutes"]
+ - path: "server/src/__tests__/31-puter-proxy.test.ts"
+ provides: "Unit tests for token storage, proxy streaming, cost recording"
+ key_links:
+ - from: "server/src/routes/puter-proxy.ts"
+ to: "server/src/services/puter-proxy.ts"
+ via: "import puterProxyService"
+ pattern: "puterProxyService"
+ - from: "server/src/services/puter-proxy.ts"
+ to: "server/src/services/secrets.ts"
+ via: "secretService getByName/create/rotate"
+ pattern: "secretService.*getByName|create|rotate"
+ - from: "server/src/services/puter-proxy.ts"
+ to: "server/src/services/costs.ts"
+ via: "costService.createEvent with provider=puter"
+ pattern: "createEvent.*puter"
+ - from: "server/src/app.ts"
+ to: "server/src/routes/puter-proxy.ts"
+ via: "api.use(puterProxyRoutes(db))"
+ pattern: "puterProxyRoutes"
+---
+
+
+Build the server-side Puter proxy service and routes that relay AI chat requests to Puter's OpenAI-compatible endpoint, store/rotate auth tokens via secretService, stream SSE responses, and record cost events.
+
+Purpose: This is the core backend for CLOUD-01 (zero-config AI via Puter) and CLOUD-02 (server-proxied adapter with cost tracking). Without this, the UI has nothing to call.
+Output: puterProxyService, puterProxyRoutes, unit tests, app.ts wiring
+
+
+
+@/home/mikkel/.claude/get-shit-done/workflows/execute-plan.md
+@/home/mikkel/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/31-puter.js-zero-config-cloud/31-RESEARCH.md
+
+
+
+
+From server/src/services/secrets.ts:
+```typescript
+export function secretService(db: Db) {
+ // ...
+ return {
+ getByName: async (companyId: string, name: string) => row | null,
+ create: async (companyId: string, input: { name: string; provider: SecretProvider; value: string; description?: string | null }, actor?) => secret,
+ rotate: async (secretId: string, input: { value: string }, actor?) => secret,
+ resolveSecretValue: async (companyId: string, secretId: string, version: number | "latest") => string,
+ // ...
+ };
+}
+```
+
+From server/src/services/costs.ts:
+```typescript
+export function costService(db: Db, budgetHooks?) {
+ return {
+ createEvent: async (companyId: string, data: {
+ agentId: string;
+ provider: string;
+ biller?: string;
+ billingType?: string;
+ model: string;
+ inputTokens: number;
+ outputTokens: number;
+ costCents: number;
+ occurredAt: Date;
+ cachedInputTokens?: number;
+ }) => event,
+ };
+}
+```
+
+From server/src/routes/authz.ts:
+```typescript
+export function assertBoard(req: Request): void;
+export function assertCompanyAccess(req: Request, companyId: string): void;
+```
+
+From server/src/routes/chat.ts (SSE pattern, lines 96-102):
+```typescript
+res.setHeader("Content-Type", "text/event-stream");
+res.setHeader("Cache-Control", "no-cache");
+res.setHeader("Connection", "keep-alive");
+res.setHeader("X-Accel-Buffering", "no");
+res.flushHeaders();
+res.write(":ok\n\n");
+```
+
+From server/src/app.ts (route mounting, lines 155-158):
+```typescript
+api.use(secretRoutes(db));
+api.use(costRoutes(db));
+// ... new routes go here, inside the api Router (after boardMutationGuard)
+```
+
+
+
+
+
+
+ Task 1: puterProxyService + puterProxyRoutes + unit tests
+
+ server/src/services/puter-proxy.ts,
+ server/src/routes/puter-proxy.ts,
+ server/src/__tests__/31-puter-proxy.test.ts
+
+
+ server/src/services/secrets.ts,
+ server/src/services/costs.ts,
+ server/src/routes/chat.ts,
+ server/src/routes/secrets.ts,
+ server/src/routes/authz.ts
+
+
+ - Test 1: storeToken creates a new secret via secretService when none exists (name: "puter_auth_token", provider: "local_encrypted")
+ - Test 2: storeToken rotates an existing secret when "puter_auth_token" already exists (calls rotate, not create)
+ - Test 3: resolveToken retrieves the stored token value via secretService.resolveSecretValue
+ - Test 4: chatStream sends POST to https://api.puter.com/puterai/openai/v1/chat/completions with Authorization Bearer header, stream: true, stream_options: { include_usage: true }
+ - Test 5: chatStream yields content strings from SSE data chunks
+ - Test 6: chatStream records a cost event with provider="puter", billingType="subscription_included", costCents=0 after stream completes
+ - Test 7: POST /api/puter-proxy/token stores token and returns 200
+ - Test 8: POST /api/puter-proxy/chat sets SSE headers and streams response chunks
+
+
+ **Create server/src/services/puter-proxy.ts:**
+
+ Factory function `puterProxyService(db: Db)` that uses `secretService(db)` and `costService(db)` internally. Returns:
+
+ - `storeToken(companyId: string, token: string)`: Call `secretService.getByName(companyId, "puter_auth_token")`. If exists, call `secretService.rotate(existing.id, { value: token })`. If not, call `secretService.create(companyId, { name: "puter_auth_token", provider: "local_encrypted", value: token, description: "Puter.com auth token for AI proxy" })`. Return the secret record.
+
+ - `resolveToken(companyId: string)`: Call `secretService.getByName(companyId, "puter_auth_token")`. Throw `unprocessable("Puter auth token not configured")` if null. Call `secretService.resolveSecretValue(companyId, secret.id, "latest")` and return string.
+
+ - `async *chatStream(companyId, agentId, messages, model, signal)`: AsyncGenerator.
+ - Constants: `PUTER_BASE_URL = "https://api.puter.com/puterai/openai/v1"`, `PUTER_DEFAULT_MODEL = "claude-3-5-haiku-20241022"`.
+ - Call `resolveToken(companyId)` to get bearer token.
+ - `fetch(`${PUTER_BASE_URL}/chat/completions`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: JSON.stringify({ model: model ?? PUTER_DEFAULT_MODEL, messages, stream: true, stream_options: { include_usage: true } }), signal })`.
+ - If `!response.ok || !response.body`, read body text and throw `Error(`Puter API error ${response.status}: ${text}`)`.
+ - Parse SSE: read from `response.body` using `getReader()`, split on `\n`, look for `data: ` prefix. Skip `[DONE]`. Parse JSON. Yield `chunk.choices?.[0]?.delta?.content`. Accumulate `usage` from chunks that have it.
+ - In `finally` block: `reader.releaseLock()`. Then record cost event non-blocking: `costService.createEvent(companyId, { agentId, provider: "puter", biller: "puter", billingType: "subscription_included", model, inputTokens, outputTokens, costCents: 0, occurredAt: new Date() }).catch(() => {})`.
+
+ **Create server/src/routes/puter-proxy.ts:**
+
+ Export `puterProxyRoutes(db: Db): Router`.
+
+ - `POST /puter-proxy/token`: `assertBoard(req)`. Extract `companyId` and `token` from `req.body`. Validate both are non-empty strings. `assertCompanyAccess(req, companyId)`. Call `puterProxyService(db).storeToken(companyId, token)`. Return `res.json({ ok: true })`.
+
+ - `POST /puter-proxy/chat`: `assertBoard(req)`. Extract `companyId`, `agentId`, `messages`, `model` from `req.body`. Validate `companyId` is string, `messages` is array with at least one entry. `assertCompanyAccess(req, companyId)`. Set SSE headers (same pattern as chat.ts lines 96-102). Create AbortController, wire `req.on("close")`. Iterate `puterProxyService(db).chatStream(...)` yielding `data: ${JSON.stringify({ token: chunk })}\n\n`. On complete write `data: ${JSON.stringify({ done: true })}\n\n`. On error write `data: ${JSON.stringify({ error: "Puter stream error" })}\n\n`. Always `res.end()`.
+
+ **Create server/src/__tests__/31-puter-proxy.test.ts:**
+
+ Use vitest. Mock `fetch` globally (vi.stubGlobal). Create a mock db object that stubs secretService and costService behavior. Test all 8 behaviors listed above. For SSE streaming test, create a mock ReadableStream that yields pre-formatted SSE chunks.
+
+ Import patterns: `import { puterProxyService } from "../services/puter-proxy.js"` and `import { puterProxyRoutes } from "../routes/puter-proxy.js"`. For route tests, use supertest with a minimal Express app.
+
+
+ cd /opt/nexus && pnpm --filter @paperclipai/server test run src/__tests__/31-puter-proxy.test.ts
+
+
+ - grep -q "puterProxyService" server/src/services/puter-proxy.ts
+ - grep -q "puter_auth_token" server/src/services/puter-proxy.ts
+ - grep -q "api.puter.com/puterai/openai/v1" server/src/services/puter-proxy.ts
+ - grep -q "subscription_included" server/src/services/puter-proxy.ts
+ - grep -q "costCents: 0" server/src/services/puter-proxy.ts
+ - grep -q "stream_options" server/src/services/puter-proxy.ts
+ - grep -q "puterProxyRoutes" server/src/routes/puter-proxy.ts
+ - grep -q "puter-proxy/token" server/src/routes/puter-proxy.ts
+ - grep -q "puter-proxy/chat" server/src/routes/puter-proxy.ts
+ - grep -q "text/event-stream" server/src/routes/puter-proxy.ts
+ - grep -q "assertBoard" server/src/routes/puter-proxy.ts
+ - All 8 tests pass
+
+
+ puterProxyService handles token storage (create/rotate idempotent), chatStream relays to Puter with streaming and cost tracking, puterProxyRoutes exposes POST /puter-proxy/token and POST /puter-proxy/chat with board auth, all tests green
+
+
+
+
+ Task 2: Mount puterProxyRoutes in app.ts
+ server/src/app.ts
+ server/src/app.ts
+
+ Add import at top of server/src/app.ts:
+ ```typescript
+ import { puterProxyRoutes } from "./routes/puter-proxy.js";
+ ```
+
+ Mount inside the `api` Router block (after `boardMutationGuard()`, near the other route mounts around line 158), add:
+ ```typescript
+ api.use(puterProxyRoutes(db));
+ ```
+
+ Place it after `api.use(costRoutes(db));` (line 158) to keep related routes together. This ensures the route is protected by boardMutationGuard (correct — Puter proxy requires board auth).
+
+
+ cd /opt/nexus && grep -n "puterProxyRoutes" server/src/app.ts
+
+
+ - grep -q "import.*puterProxyRoutes.*from.*routes/puter-proxy" server/src/app.ts
+ - grep -q "api.use(puterProxyRoutes(db))" server/src/app.ts
+
+ puterProxyRoutes mounted inside api Router with board auth protection; server compiles without errors
+
+
+
+
+
+- `cd /opt/nexus && pnpm --filter @paperclipai/server test run src/__tests__/31-puter-proxy.test.ts` — all tests pass
+- `cd /opt/nexus && pnpm --filter @paperclipai/server exec tsc --noEmit` — no TypeScript errors
+- grep confirms route is mounted in app.ts
+
+
+
+- Puter proxy service exists with token storage, streaming chat, and cost tracking
+- Routes exist for POST /puter-proxy/token and POST /puter-proxy/chat
+- Routes mounted in app.ts behind boardMutationGuard
+- All unit tests pass
+
+
+
diff --git a/.planning/phases/31-puter.js-zero-config-cloud/31-02-PLAN.md b/.planning/phases/31-puter.js-zero-config-cloud/31-02-PLAN.md
new file mode 100644
index 00000000..ad5bb8f6
--- /dev/null
+++ b/.planning/phases/31-puter.js-zero-config-cloud/31-02-PLAN.md
@@ -0,0 +1,210 @@
+---
+phase: 31-puter.js-zero-config-cloud
+plan: "02"
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - server/src/services/google-oauth.ts
+ - server/src/routes/google-oauth.ts
+ - server/src/app.ts
+autonomous: true
+requirements: [CLOUD-03, CLOUD-05]
+
+must_haves:
+ truths:
+ - "Server can generate a Google OAuth PKCE authorization URL with correct client_id, scopes, code_challenge"
+ - "Server can exchange an authorization code for access/refresh tokens via Google's token endpoint"
+ - "Google OAuth tokens are stored server-side via secretService as google_gemini_oauth_token"
+ - "POST /api/puter-proxy/token supports storing arbitrary provider tokens (reused for API key entry)"
+ artifacts:
+ - path: "server/src/services/google-oauth.ts"
+ provides: "googleOAuthService with generateAuthUrl, exchangeCode, storeTokens methods"
+ exports: ["googleOAuthService"]
+ - path: "server/src/routes/google-oauth.ts"
+ provides: "Routes for GET /api/oauth/google/authorize and GET /api/oauth/google/callback"
+ exports: ["googleOAuthRoutes"]
+ key_links:
+ - from: "server/src/services/google-oauth.ts"
+ to: "server/src/services/secrets.ts"
+ via: "secretService getByName/create/rotate for google_gemini_oauth_token"
+ pattern: "google_gemini_oauth_token"
+ - from: "server/src/routes/google-oauth.ts"
+ to: "server/src/services/google-oauth.ts"
+ via: "import googleOAuthService"
+ pattern: "googleOAuthService"
+---
+
+
+Build the Google OAuth PKCE service and routes for Gemini free tier access, plus ensure the token storage endpoint supports API key entry for subscription providers.
+
+Purpose: CLOUD-03 (Google OAuth for Gemini) and CLOUD-05 (API key entry). The OAuth callback needs a server-side route to exchange the code for tokens.
+Output: googleOAuthService, googleOAuthRoutes, app.ts wiring
+
+
+
+@/home/mikkel/.claude/get-shit-done/workflows/execute-plan.md
+@/home/mikkel/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/31-puter.js-zero-config-cloud/31-RESEARCH.md
+
+
+From server/src/services/secrets.ts:
+```typescript
+export function secretService(db: Db) {
+ return {
+ getByName: async (companyId: string, name: string) => row | null,
+ create: async (companyId, { name, provider, value, description }, actor?) => secret,
+ rotate: async (secretId, { value }, actor?) => secret,
+ resolveSecretValue: async (companyId, secretId, "latest") => string,
+ };
+}
+```
+
+From server/src/routes/authz.ts:
+```typescript
+export function assertBoard(req): void;
+export function assertCompanyAccess(req, companyId): void;
+```
+
+From server/src/app.ts (route mounting):
+```typescript
+// Unauthenticated routes mounted before api Router:
+app.use("/api", hardwareRoutes()); // line 132
+
+// Authenticated routes inside api Router:
+api.use(secretRoutes(db)); // line 157
+```
+
+
+
+
+
+
+ Task 1: googleOAuthService — PKCE generation, code exchange, token storage
+ server/src/services/google-oauth.ts
+
+ server/src/services/secrets.ts,
+ server/src/routes/secrets.ts
+
+
+ Create `server/src/services/google-oauth.ts`. Export `googleOAuthService(db: Db)`.
+
+ **Constants:**
+ ```typescript
+ const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
+ const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
+ const GEMINI_CLIENT_ID = "812546505895-ag9nvbqvf8cpqk3mfem1glig0jtl5i31.apps.googleusercontent.com";
+ const GEMINI_SCOPES = "openid https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/generative-language.retriever";
+ const GOOGLE_TOKEN_SECRET_NAME = "google_gemini_oauth_token";
+ ```
+
+ The client_id is the publicly documented Gemini CLI installed-app client_id (per RESEARCH.md).
+
+ **Methods:**
+
+ - `generatePkce()`: Use `crypto.randomBytes(32).toString("base64url")` for verifier. `crypto.createHash("sha256").update(verifier).digest("base64url")` for challenge. Return `{ verifier, challenge }`.
+
+ - `generateAuthUrl(redirectUri: string, state: string)`: Generate PKCE. Build URL with params: `client_id=GEMINI_CLIENT_ID`, `redirect_uri=redirectUri`, `response_type=code`, `scope=GEMINI_SCOPES`, `code_challenge=challenge`, `code_challenge_method=S256`, `state=state`, `access_type=offline`, `prompt=consent`. Return `{ url, verifier }`. Caller must store verifier temporarily (in-memory Map keyed by state).
+
+ - `exchangeCode(code: string, redirectUri: string, verifier: string)`: POST to `GOOGLE_TOKEN_URL` with `grant_type=authorization_code`, `client_id=GEMINI_CLIENT_ID`, `code`, `redirect_uri=redirectUri`, `code_verifier=verifier`. Parse JSON response. Return `{ accessToken, refreshToken, expiresIn }`.
+
+ - `storeTokens(companyId: string, tokens: { accessToken: string; refreshToken?: string })`: Store JSON.stringify(tokens) via secretService upsert pattern (getByName → exists ? rotate : create) under name `google_gemini_oauth_token`, provider `local_encrypted`.
+
+ - `resolveTokens(companyId: string)`: Get secret by name, resolve value, JSON.parse, return `{ accessToken, refreshToken }`.
+
+ Use `import crypto from "node:crypto";` for PKCE. Use `fetch` (Node built-in) for token exchange.
+
+
+ cd /opt/nexus && grep -c "generatePkce\|exchangeCode\|storeTokens\|resolveTokens\|generateAuthUrl" server/src/services/google-oauth.ts
+
+
+ - grep -q "googleOAuthService" server/src/services/google-oauth.ts
+ - grep -q "google_gemini_oauth_token" server/src/services/google-oauth.ts
+ - grep -q "812546505895" server/src/services/google-oauth.ts
+ - grep -q "code_challenge_method" server/src/services/google-oauth.ts
+ - grep -q "randomBytes" server/src/services/google-oauth.ts
+ - grep -q "base64url" server/src/services/google-oauth.ts
+ - grep -q "oauth2.googleapis.com/token" server/src/services/google-oauth.ts
+
+ googleOAuthService generates PKCE auth URLs, exchanges codes for tokens, and stores/retrieves tokens via secretService
+
+
+
+ Task 2: googleOAuthRoutes + API key storage route + mount in app.ts
+
+ server/src/routes/google-oauth.ts,
+ server/src/app.ts
+
+
+ server/src/app.ts,
+ server/src/routes/secrets.ts,
+ server/src/routes/authz.ts
+
+
+ **Create server/src/routes/google-oauth.ts:**
+
+ Export `googleOAuthRoutes(db: Db): Router`.
+
+ In-memory Map for PKCE state: `const pendingPkce = new Map()`. Clean up entries older than 10 minutes on each request.
+
+ Routes:
+
+ - `POST /oauth/google/authorize`: `assertBoard(req)`. Extract `companyId` from `req.body`. `assertCompanyAccess(req, companyId)`. Generate random `state` via `crypto.randomUUID()`. Construct `redirectUri` from `req.protocol + "://" + req.get("host") + "/api/oauth/google/callback"`. Call `googleOAuthService(db).generateAuthUrl(redirectUri, state)`. Store `{ verifier, companyId, createdAt: Date.now() }` in `pendingPkce` keyed by `state`. Return `res.json({ url })`.
+
+ - `GET /oauth/google/callback`: Extract `code` and `state` from `req.query`. Look up `pendingPkce.get(state)`. If not found, return `res.status(400).send("Invalid or expired OAuth state")`. Delete from map. Construct `redirectUri` same as above. Call `googleOAuthService(db).exchangeCode(code, redirectUri, verifier)`. Call `googleOAuthService(db).storeTokens(companyId, { accessToken, refreshToken })`. Redirect to a UI success page: `res.redirect("/?google_oauth=success")`. Wrap in try/catch — on error, redirect to `/?google_oauth=error`.
+
+ - `POST /api-keys/store`: `assertBoard(req)`. Extract `companyId`, `provider` (string: "openai" | "anthropic" | "groq"), `apiKey` from `req.body`. Validate all present. `assertCompanyAccess(req, companyId)`. Store via secretService upsert pattern with name `${provider}_api_key` (e.g., "openai_api_key"), provider "local_encrypted". Return `res.json({ ok: true })`.
+
+ **Mount in app.ts:**
+
+ Add import: `import { googleOAuthRoutes } from "./routes/google-oauth.js";`
+
+ The OAuth callback (`GET /oauth/google/callback`) needs to be accessible without boardMutationGuard blocking the GET (GETs pass through boardMutationGuard since it only blocks mutations). The authorize and api-keys/store endpoints need board auth. Mount inside the `api` Router:
+
+ ```typescript
+ api.use(googleOAuthRoutes(db));
+ ```
+
+ Place after `puterProxyRoutes(db)` (or after `costRoutes(db)` if plan 01 hasn't run yet — the executor should place it near the other new routes).
+
+
+ cd /opt/nexus && grep -n "googleOAuthRoutes" server/src/app.ts && grep -c "oauth/google/authorize\|oauth/google/callback\|api-keys/store" server/src/routes/google-oauth.ts
+
+
+ - grep -q "googleOAuthRoutes" server/src/routes/google-oauth.ts
+ - grep -q "oauth/google/authorize" server/src/routes/google-oauth.ts
+ - grep -q "oauth/google/callback" server/src/routes/google-oauth.ts
+ - grep -q "api-keys/store" server/src/routes/google-oauth.ts
+ - grep -q "pendingPkce" server/src/routes/google-oauth.ts
+ - grep -q "assertBoard" server/src/routes/google-oauth.ts
+ - grep -q "import.*googleOAuthRoutes" server/src/app.ts
+ - grep -q "api.use(googleOAuthRoutes" server/src/app.ts
+
+ Google OAuth PKCE routes handle authorize + callback flow, API key storage route accepts provider/key pairs, both mounted in app.ts with board auth
+
+
+
+
+
+- `cd /opt/nexus && pnpm --filter @paperclipai/server exec tsc --noEmit` — no TypeScript errors
+- grep confirms all routes and services exist with correct exports
+- Google OAuth routes handle full PKCE flow (authorize URL generation, callback code exchange, token storage)
+- API key storage route supports OpenAI/Anthropic/Groq key entry
+
+
+
+- googleOAuthService generates PKCE auth URLs, exchanges codes, stores tokens
+- Routes exist for POST /oauth/google/authorize, GET /oauth/google/callback, POST /api-keys/store
+- All routes mounted in app.ts
+- Server compiles without TypeScript errors
+
+
+
diff --git a/.planning/phases/31-puter.js-zero-config-cloud/31-03-PLAN.md b/.planning/phases/31-puter.js-zero-config-cloud/31-03-PLAN.md
new file mode 100644
index 00000000..7fedf0e2
--- /dev/null
+++ b/.planning/phases/31-puter.js-zero-config-cloud/31-03-PLAN.md
@@ -0,0 +1,368 @@
+---
+phase: 31-puter.js-zero-config-cloud
+plan: "03"
+type: execute
+wave: 2
+depends_on: ["31-01", "31-02"]
+files_modified:
+ - ui/src/components/onboarding/ProviderSelectionStep.tsx
+ - ui/src/components/onboarding/PuterAuthButton.tsx
+ - ui/src/components/onboarding/GoogleOAuthButton.tsx
+ - ui/src/components/onboarding/ApiKeyEntryForm.tsx
+ - ui/src/components/NexusOnboardingWizard.tsx
+ - ui/src/api/puter-proxy.ts
+autonomous: true
+requirements: [CLOUD-01, CLOUD-03, CLOUD-04, CLOUD-05]
+
+must_haves:
+ truths:
+ - "User sees a Provider Selection step (Step 3 of 4) in the onboarding wizard with Puter, Google, and API Key options"
+ - "Clicking 'Continue with Puter' triggers Puter auth popup, stores token server-side, and advances wizard"
+ - "Google option shows policy-risk warning before enabling the Sign in button (3-second gate)"
+ - "API key option shows inline form with provider dropdown and key input"
+ - "Skip for now button is always visible and advances to root directory step"
+ - "Pre-installed tools (Hermes, Claude Code, OpenClaw) show detected badges via adapter probe"
+ - "Step indicator shows 'Step N of 4' (updated from 3)"
+ artifacts:
+ - path: "ui/src/components/onboarding/ProviderSelectionStep.tsx"
+ provides: "Provider selection UI with three option cards and skip button"
+ exports: ["ProviderSelectionStep"]
+ - path: "ui/src/components/onboarding/PuterAuthButton.tsx"
+ provides: "Puter auth button that loads CDN script, calls signIn, posts token to server"
+ exports: ["PuterAuthButton"]
+ - path: "ui/src/components/onboarding/GoogleOAuthButton.tsx"
+ provides: "Google OAuth button with 3-second risk warning gate"
+ exports: ["GoogleOAuthButton"]
+ - path: "ui/src/components/onboarding/ApiKeyEntryForm.tsx"
+ provides: "API key entry form with provider dropdown"
+ exports: ["ApiKeyEntryForm"]
+ - path: "ui/src/api/puter-proxy.ts"
+ provides: "API client for puter-proxy and oauth endpoints"
+ exports: ["puterProxyApi"]
+ - path: "ui/src/components/NexusOnboardingWizard.tsx"
+ provides: "4-step wizard with provider selection step inserted"
+ key_links:
+ - from: "ui/src/components/onboarding/PuterAuthButton.tsx"
+ to: "POST /api/puter-proxy/token"
+ via: "fetch call to store Puter token server-side"
+ pattern: "puter-proxy/token"
+ - from: "ui/src/components/onboarding/GoogleOAuthButton.tsx"
+ to: "POST /api/oauth/google/authorize"
+ via: "fetch call to get auth URL, then window.open"
+ pattern: "oauth/google/authorize"
+ - from: "ui/src/components/onboarding/ApiKeyEntryForm.tsx"
+ to: "POST /api/api-keys/store"
+ via: "fetch call to store API key"
+ pattern: "api-keys/store"
+ - from: "ui/src/components/NexusOnboardingWizard.tsx"
+ to: "ui/src/components/onboarding/ProviderSelectionStep.tsx"
+ via: "import and render in step 3"
+ pattern: "ProviderSelectionStep"
+---
+
+
+Build the Provider Selection step UI components and wire them into the existing 3-step onboarding wizard (making it 4 steps). This includes the Puter auth button, Google OAuth button with risk warning, API key entry form, and adapter auto-detection badges.
+
+Purpose: CLOUD-01 (Puter zero-config UI), CLOUD-03 (Google OAuth UI), CLOUD-04 (auto-detect tools), CLOUD-05 (API key entry UI). This is the user-facing surface for all cloud provider paths.
+Output: ProviderSelectionStep, PuterAuthButton, GoogleOAuthButton, ApiKeyEntryForm, updated wizard
+
+
+
+@/home/mikkel/.claude/get-shit-done/workflows/execute-plan.md
+@/home/mikkel/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/31-puter.js-zero-config-cloud/31-RESEARCH.md
+@.planning/phases/31-puter.js-zero-config-cloud/31-UI-SPEC.md
+@.planning/phases/30-hardware-detection-mode-selection/30-02-SUMMARY.md
+
+
+From ui/src/components/NexusOnboardingWizard.tsx (current state):
+```typescript
+// 3-step wizard: step 1 = hardware, step 2 = mode, step 3 = root directory
+// Step indicator: "Step {step} of 3"
+// defaultAdapter state: "claude_local" | "hermes_local"
+// probing state for adapter detection
+// handleSubmit creates company + agents on step 3 form submit
+```
+
+From ui/src/components/onboarding/ModeSelector.tsx (selected state pattern):
+```typescript
+// Cards use: border-primary bg-primary/5 when selected
+// Vertical stack with gap-3
+// Each card is a
+
+
+
+
+
+ Task 1: API client + PuterAuthButton + GoogleOAuthButton + ApiKeyEntryForm
+
+ ui/src/api/puter-proxy.ts,
+ ui/src/components/onboarding/PuterAuthButton.tsx,
+ ui/src/components/onboarding/GoogleOAuthButton.tsx,
+ ui/src/components/onboarding/ApiKeyEntryForm.tsx
+
+
+ ui/src/api/agents.ts,
+ ui/src/api/client.ts,
+ ui/src/components/onboarding/ModeSelector.tsx,
+ ui/src/components/NexusOnboardingWizard.tsx,
+ .planning/phases/31-puter.js-zero-config-cloud/31-UI-SPEC.md
+
+
+ **Create ui/src/api/puter-proxy.ts:**
+
+ Import `api` from `./client`. Export `puterProxyApi` with:
+ - `storeToken: (companyId: string, token: string) => api.post("/puter-proxy/token", { companyId, token })`
+ - `getAuthUrl: (companyId: string) => api.post<{ url: string }>("/oauth/google/authorize", { companyId })`
+ - `storeApiKey: (companyId: string, provider: string, apiKey: string) => api.post("/api-keys/store", { companyId, provider, apiKey })`
+
+ **Create ui/src/components/onboarding/PuterAuthButton.tsx:**
+
+ Props: `{ companyId?: string; onSuccess: () => void; onError: (msg: string) => void }`.
+
+ State: `loading` boolean.
+
+ `loadScript` helper: check if `window.puter` exists; if not, create a `