Files
workspace/docs/superpowers/specs/2026-05-16-cloud-scout-creation-flow-plan.md
Roberto 93353c7867 docs: add cloud scout creation flow implementation plan
15 tasks across BE cloud CRUD (foundational — routes never existed),
gmail_address + connector list_labels/stop_watch, tRPC + shared types,
Electron stepper split (Local/Cloud flows) + config panel parity +
catalog gating + i18n.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:10:52 +02:00

74 KiB
Raw Blame History

Cloud Scout Creation Flow (Gmail) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Give Gmail cloud scouts a slim creation flow (name + focus + auto-trash + label/sender filter, OAuth during creation) fitting the two-stage HITL pipeline, with full edit parity in the config panel — and build the foundational cloud-scout CRUD backend routes that were never implemented.

Architecture: Backend gains the missing CloudScoutConfig CRUD routes (POST/GET/PUT/DELETE /api/v1/scouts/cloud) plus a gmail_address column, Gmail label-listing and disconnect endpoints. The Electron stepper is split into a thin router that delegates to LocalScoutCreationFlow (unchanged) or a new CloudScoutCreationFlow. The cloud config panel is rewritten for edit parity.

Tech Stack: Python 3.12 / FastAPI / SQLAlchemy 2.0 async / Alembic / Pydantic v2 / Google API client. Electron / TypeScript / React 19 / tRPC v11 / zod / i18next.

Spec: docs/superpowers/specs/2026-05-16-cloud-scout-creation-flow-design.md

CRITICAL CONTEXT — discovered during planning: The backend cloud-scout CRUD routes (/api/v1/scouts/cloud) do not exist. The Electron tRPC scout.cloud.{list,create,update,delete} procedures call them but they 404. Cloud scouts are currently non-functional end-to-end. Phase A below builds these foundational routes; the slim-flow work (Phases CD) depends on them. This is a larger scope than the spec literally implied ("verify/extend") because there is nothing to extend yet.

Repository layout: monorepo with submodules api/ (FastAPI) and adiuvAI/ (Electron), both on branch develop. Monorepo on main. Don't bump submodule pointers until the final task of each submodule's work.

Working dir: c:\Users\PC-Roby\Documents\_adiuvai_workspace. Windows; use the Bash tool for git/pytest. Pre-existing flaky tests to ignore throughout: test_eval_runner, test_eval_journey, test_home_request_calls_memory_middleware (hangs), test_stream_end_serializes.


File Structure

Backend (api/)

Path Responsibility New/Modified
api/alembic/versions/009_cloud_scout_gmail_address.py Add gmail_address column New
api/app/models.py CloudScoutConfig.gmail_address field Modified
api/app/schemas/__init__.py Cloud create/update/response Pydantic models Modified
api/app/api/routes/scouts.py Cloud CRUD routes + serializer + label-list + disconnect + gmail_address persistence Modified
api/app/scouts/connectors/gmail.py list_labels + stop_watch Modified
api/tests/test_scout_cloud_crud.py Cloud CRUD route tests New
api/tests/test_scout_connectors_gmail.py list_labels + stop_watch tests Modified

Electron (adiuvAI/)

Path Responsibility New/Modified
adiuvAI/src/shared/api-types.ts CloudScoutConfigSchema new fields Modified
adiuvAI/src/main/router/index.ts tRPC cloud input changes + gmailLabels + disconnectGmail Modified
adiuvAI/src/renderer/components/settings/InlineScoutCreationStepper.tsx Thin router: shared template step → delegate Modified
adiuvAI/src/renderer/components/settings/LocalScoutCreationFlow.tsx Extracted current local flow New
adiuvAI/src/renderer/components/settings/CloudScoutCreationFlow.tsx New Gmail slim flow New
adiuvAI/src/renderer/components/settings/CloudScoutConfigPanel.tsx Rewrite for parity Modified
adiuvAI/src/renderer/components/settings/TemplateSelectCard.tsx Disable Teams/Outlook Modified
adiuvAI/src/renderer/locales/{en,it,es,fr,de}/translation.json New scout keys Modified

PHASE A — Backend Cloud CRUD Routes (foundational)

Task 1: Cloud scout Pydantic schemas

Files:

  • Modify: api/app/schemas/__init__.py

Context: No request/response schemas exist for cloud-scout CRUD. Add them near the other scout schemas (ScoutCatalogItem, ScoutCreationCheckResponse, etc). The response mirrors the CloudScoutConfig ORM columns, camelCased by the existing serialization layer (the route returns a plain dict; the Electron proxyGet camelCases keys, so we return snake_case keys and let the client convert — verify by checking how ScoutRunLogResponse is returned).

  • Step 1: Read the existing scout schemas to match style

Read api/app/schemas/__init__.py around the ScoutCatalogItem / ScoutCreationCheckResponse / ScoutRunLogResponse definitions. Note whether response models use alias_generator/camelCase or snake_case + client conversion. (The Electron proxyGet/proxyPost camelCases responses and snake_cases requests, so backend speaks snake_case.)

  • Step 2: Add the cloud schemas

Add to api/app/schemas/__init__.py:

class CloudScoutCreateRequest(BaseModel):
    name: str
    provider: Literal["gmail", "teams", "outlook"]
    data_types: list[str] = Field(default_factory=list)
    prompt_template: str = ""
    schedule_cron: str | None = None        # None → server default
    filter_config: dict | None = None
    auto_trash_spam: bool = False


class CloudScoutUpdateRequest(BaseModel):
    name: str | None = None
    data_types: list[str] | None = None
    prompt_template: str | None = None
    schedule_cron: str | None = None
    filter_config: dict | None = None
    auto_trash_spam: bool | None = None
    enabled: bool | None = None


class CloudScoutResponse(BaseModel):
    id: str
    user_id: str
    provider: str
    name: str
    data_types: list[str]
    prompt_template: str
    schedule_cron: str
    filter_config: dict | None
    auto_trash_spam: bool
    enabled: bool
    last_run_at: int | None
    gmail_address: str | None
    oauth_connected: bool
    created_at: int
    updated_at: int

If Literal / Field aren't imported in this file, add them: from typing import Literal and from pydantic import BaseModel, Field.

  • Step 3: Verify import
cd api
python -c "from app.schemas import CloudScoutCreateRequest, CloudScoutUpdateRequest, CloudScoutResponse; print('ok')"

Expected: ok.

  • Step 4: Commit
cd api
git add app/schemas/__init__.py
git commit -m "feat(scouts): add cloud scout CRUD pydantic schemas"

Task 2: Cloud scout serializer + CRUD routes

Files:

  • Modify: api/app/api/routes/scouts.py
  • Test: api/tests/test_scout_cloud_crud.py

Context: scouts.py already has _dt_ms (line 60), _dt_ms_opt (line 64), _to_data_types (line 68), _enforce_agent_limit (line 99), and uses Depends(get_session) + Depends(get_current_user) returning UserProfile. Mirror the existing route style. The serializer computes oauth_connected from oauth_token_encrypted is not None.

  • Step 1: Write the failing tests

Create api/tests/test_scout_cloud_crud.py:

"""Tests for cloud scout CRUD routes."""

from __future__ import annotations

import uuid
from unittest.mock import patch

import pytest
from httpx import ASGITransport, AsyncClient

from app.main import app
from app.models import CloudScoutConfig
from tests.conftest import _TestSessionLocal, make_jwt


def _auth_headers(tier: str = "power") -> dict:
    return {"Authorization": f"Bearer {make_jwt(tier)}"}


@pytest.fixture(autouse=True)
def _patch_session():
    # Route handlers use app.db.get_session; point it at the test session.
    with patch("app.api.routes.scouts.get_session", new=_test_get_session):
        yield


async def _test_get_session():
    async with _TestSessionLocal() as session:
        yield session


@pytest.mark.asyncio
async def test_create_cloud_scout_defaults_schedule():
    payload = {
        "name": "Inbox",
        "provider": "gmail",
        "data_types": [],
        "prompt_template": "client requests",
        "auto_trash_spam": True,
        # schedule_cron omitted → server default
    }
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
        resp = await client.post("/api/v1/scouts/cloud", json=payload, headers=_auth_headers())
    assert resp.status_code == 201, resp.text
    body = resp.json()
    assert body["name"] == "Inbox"
    assert body["provider"] == "gmail"
    assert body["auto_trash_spam"] is True
    assert body["prompt_template"] == "client requests"
    assert body["schedule_cron"]  # non-empty default applied
    assert body["oauth_connected"] is False
    assert body["gmail_address"] is None


@pytest.mark.asyncio
async def test_list_cloud_scouts_returns_only_own():
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
        await client.post(
            "/api/v1/scouts/cloud",
            json={"name": "A", "provider": "gmail"},
            headers=_auth_headers(),
        )
        resp = await client.get("/api/v1/scouts/cloud", headers=_auth_headers())
    assert resp.status_code == 200
    rows = resp.json()
    assert all(r["provider"] == "gmail" for r in rows)
    assert any(r["name"] == "A" for r in rows)


@pytest.mark.asyncio
async def test_update_cloud_scout_applies_filter_and_autotrash():
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
        created = (await client.post(
            "/api/v1/scouts/cloud",
            json={"name": "B", "provider": "gmail"},
            headers=_auth_headers(),
        )).json()
        sid = created["id"]
        resp = await client.put(
            f"/api/v1/scouts/cloud/{sid}",
            json={"filter_config": {"labels": ["INBOX"], "senders": ["@client.co"]}, "auto_trash_spam": True, "prompt_template": "invoices"},
            headers=_auth_headers(),
        )
    assert resp.status_code == 200, resp.text
    body = resp.json()
    assert body["filter_config"] == {"labels": ["INBOX"], "senders": ["@client.co"]}
    assert body["auto_trash_spam"] is True
    assert body["prompt_template"] == "invoices"


@pytest.mark.asyncio
async def test_delete_cloud_scout():
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
        created = (await client.post(
            "/api/v1/scouts/cloud",
            json={"name": "C", "provider": "gmail"},
            headers=_auth_headers(),
        )).json()
        sid = created["id"]
        resp = await client.delete(f"/api/v1/scouts/cloud/{sid}", headers=_auth_headers())
        assert resp.status_code == 200
        listing = (await client.get("/api/v1/scouts/cloud", headers=_auth_headers())).json()
    assert all(r["id"] != sid for r in listing)

Note: the _patch_session fixture assumes the route reads get_session as a module-level name in scouts.py. If get_session is imported differently, adjust the patch target. Confirm via grep -n "get_session" api/app/api/routes/scouts.py.

  • Step 2: Run tests to confirm they fail
cd api
pytest tests/test_scout_cloud_crud.py -v

Expected: 404s (routes don't exist) → assertion failures.

  • Step 3: Add the serializer + routes to scouts.py

Add near the other helpers in api/app/api/routes/scouts.py:

def _to_cloud_response(scout: CloudScoutConfig) -> dict:
    return {
        "id": scout.id,
        "user_id": scout.user_id,
        "provider": scout.provider,
        "name": scout.name,
        "data_types": scout.data_types or [],
        "prompt_template": scout.prompt_template or "",
        "schedule_cron": scout.schedule_cron,
        "filter_config": scout.filter_config,
        "auto_trash_spam": scout.auto_trash_spam,
        "enabled": scout.enabled,
        "last_run_at": _dt_ms_opt(scout.last_run_at),
        "gmail_address": scout.gmail_address,
        "oauth_connected": scout.oauth_token_encrypted is not None,
        "created_at": _dt_ms(scout.created_at),
        "updated_at": _dt_ms(scout.updated_at),
    }

Then add the routes (place after the existing scout routes, before the OAuth section):

_DEFAULT_CLOUD_SCHEDULE = "0 */6 * * *"


@router.get("/cloud", response_model=list[CloudScoutResponse])
async def list_cloud_scouts(
    db: AsyncSession = Depends(get_session),
    current_user: UserProfile = Depends(get_current_user),
):
    rows = (await db.execute(
        select(CloudScoutConfig).where(CloudScoutConfig.user_id == current_user.id)
    )).scalars().all()
    return [_to_cloud_response(s) for s in rows]


@router.post("/cloud", response_model=CloudScoutResponse, status_code=status.HTTP_201_CREATED)
async def create_cloud_scout(
    body: CloudScoutCreateRequest,
    db: AsyncSession = Depends(get_session),
    current_user: UserProfile = Depends(get_current_user),
):
    scout = CloudScoutConfig(
        id=str(uuid.uuid4()),
        user_id=current_user.id,
        provider=body.provider,
        name=body.name,
        data_types=body.data_types,
        prompt_template=body.prompt_template,
        filter_config=body.filter_config,
        schedule_cron=body.schedule_cron or _DEFAULT_CLOUD_SCHEDULE,
        auto_trash_spam=body.auto_trash_spam,
        enabled=True,
    )
    db.add(scout)
    await db.commit()
    await db.refresh(scout)
    return _to_cloud_response(scout)


@router.put("/cloud/{scout_id}", response_model=CloudScoutResponse)
async def update_cloud_scout(
    scout_id: str,
    body: CloudScoutUpdateRequest,
    db: AsyncSession = Depends(get_session),
    current_user: UserProfile = Depends(get_current_user),
):
    scout = await db.get(CloudScoutConfig, scout_id)
    if scout is None or scout.user_id != current_user.id:
        raise HTTPException(status.HTTP_404_NOT_FOUND, "Scout not found")
    if body.name is not None:
        scout.name = body.name
    if body.data_types is not None:
        scout.data_types = body.data_types
    if body.prompt_template is not None:
        scout.prompt_template = body.prompt_template
    if body.schedule_cron is not None:
        scout.schedule_cron = body.schedule_cron
    if body.filter_config is not None:
        scout.filter_config = body.filter_config
    if body.auto_trash_spam is not None:
        scout.auto_trash_spam = body.auto_trash_spam
    if body.enabled is not None:
        scout.enabled = body.enabled
    await db.commit()
    await db.refresh(scout)
    return _to_cloud_response(scout)


@router.delete("/cloud/{scout_id}")
async def delete_cloud_scout(
    scout_id: str,
    db: AsyncSession = Depends(get_session),
    current_user: UserProfile = Depends(get_current_user),
):
    scout = await db.get(CloudScoutConfig, scout_id)
    if scout is None or scout.user_id != current_user.id:
        raise HTTPException(status.HTTP_404_NOT_FOUND, "Scout not found")
    await db.delete(scout)
    await db.commit()
    return {"ok": True}

Ensure imports at the top of scouts.py include: uuid, select (from sqlalchemy), and the three new schemas CloudScoutCreateRequest, CloudScoutUpdateRequest, CloudScoutResponse. Add to the existing from app.schemas import (...) block and from sqlalchemy import select if missing.

  • Step 4: Run the tests
cd api
pytest tests/test_scout_cloud_crud.py -v

Expected: 4 PASS. If the get_session patch target is wrong, fix it and re-run.

  • Step 5: Commit
cd api
git add app/api/routes/scouts.py tests/test_scout_cloud_crud.py
git commit -m "feat(scouts): add cloud scout CRUD routes + serializer"

PHASE B — gmail_address + connector additions

Task 3: Alembic 009 — gmail_address column + model field

Files:

  • Create: api/alembic/versions/009_cloud_scout_gmail_address.py

  • Modify: api/app/models.py

  • Step 1: Create the migration

"""Add gmail_address to cloud_scout_configs.

Revision ID: 009
Revises: 008
Create Date: 2026-05-16
"""

from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op


revision: str = "009"
down_revision: Union[str, None] = "008"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
    op.add_column("cloud_scout_configs", sa.Column("gmail_address", sa.String(320), nullable=True))


def downgrade() -> None:
    op.drop_column("cloud_scout_configs", "gmail_address")
  • Step 2: Verify revision graph
cd api
python -c "from alembic.script import ScriptDirectory; from alembic.config import Config; sd = ScriptDirectory.from_config(Config('alembic.ini')); print([s.revision for s in sd.walk_revisions()][:3])"

Expected: ['009', '008', '007'].

  • Step 3: Add the model field

In api/app/models.py, inside CloudScoutConfig (after device_inactivity_pause_days):

gmail_address: Mapped[str | None] = mapped_column(String(320), nullable=True)
  • Step 4: Run scout tests to confirm create_all still works
cd api
pytest tests/test_scout_cloud_crud.py tests/test_scout_engine.py -v

Expected: green (the test DB uses create_all, picks up the new column).

  • Step 5: Commit
cd api
git add alembic/versions/009_cloud_scout_gmail_address.py app/models.py
git commit -m "feat(scouts): add gmail_address column to cloud_scout_configs"

Task 4: Persist gmail_address on OAuth callback

Files:

  • Modify: api/app/api/routes/scouts.py (the scout_gmail_oauth_callback handler, lines 370-440)

Context: After token exchange, fetch the Gmail profile to get emailAddress and store it. The connector's _get_gmail_service can build a service, but at callback time the token is freshly minted — simplest is to call Gmail's getProfile via a one-off service built from creds_dict.

  • Step 1: Add gmail_address persistence after token storage

In scout_gmail_oauth_callback, after scout.oauth_token_encrypted = encrypted and before the first await db.commit(), add:

    # Fetch the connected Gmail address for display.
    try:
        import asyncio
        from googleapiclient.discovery import build
        from google.oauth2.credentials import Credentials

        def _fetch_email() -> str | None:
            creds = Credentials(
                token=creds_dict["token"],
                refresh_token=creds_dict.get("refresh_token"),
                token_uri=creds_dict["token_uri"],
                client_id=creds_dict["client_id"],
                client_secret=creds_dict["client_secret"],
                scopes=creds_dict["scopes"],
            )
            service = build("gmail", "v1", credentials=creds, cache_discovery=False)
            profile = service.users().getProfile(userId="me").execute()
            return profile.get("emailAddress")

        scout.gmail_address = await asyncio.to_thread(_fetch_email)
    except Exception:
        logger.exception("failed to fetch gmail address for scout %s", scout_id)
  • Step 2: Verify the handler still imports/compiles
cd api
python -c "import app.api.routes.scouts; print('ok')"

Expected: ok.

  • Step 3: Commit
cd api
git add app/api/routes/scouts.py
git commit -m "feat(scouts): persist connected gmail_address on oauth callback"

Task 5: GmailConnector.list_labels + stop_watch

Files:

  • Modify: api/app/scouts/connectors/gmail.py
  • Modify: api/tests/test_scout_connectors_gmail.py

Context: _get_gmail_service(scout) (line 48) returns a sync Google API service. Mirror the existing archive / fetch_metadata asyncio.to_thread(_sync) pattern.

  • Step 1: Write the failing tests

Append to api/tests/test_scout_connectors_gmail.py:

@pytest.mark.asyncio
async def test_list_labels_returns_id_and_name():
    scout = _make_scout()
    conn = GmailConnector()
    fake = {"labels": [
        {"id": "INBOX", "name": "INBOX", "type": "system"},
        {"id": "Label_1", "name": "Work", "type": "user"},
    ]}
    with patch("app.scouts.connectors.gmail._get_gmail_service") as mock_svc:
        mock_svc.return_value.users().labels().list().execute.return_value = fake
        labels = await conn.list_labels(scout)
    assert {"id": "INBOX", "name": "INBOX"} in labels
    assert {"id": "Label_1", "name": "Work"} in labels


@pytest.mark.asyncio
async def test_stop_watch_calls_stop():
    scout = _make_scout()
    conn = GmailConnector()
    with patch("app.scouts.connectors.gmail._get_gmail_service") as mock_svc:
        await conn.stop_watch(scout)
        mock_svc.return_value.users().stop.assert_called()

(_make_scout already exists in this test file from Phase 3.)

  • Step 2: Run to confirm fail
cd api
pytest tests/test_scout_connectors_gmail.py -k "list_labels or stop_watch" -v

Expected: AttributeError (methods missing).

  • Step 3: Implement the two methods

Add to the GmailConnector class in api/app/scouts/connectors/gmail.py:

    async def list_labels(self, scout) -> list[dict]:
        """Return the account's Gmail labels as [{id, name}]. Empty if no token."""
        if not scout.oauth_token_encrypted:
            return []

        def _sync() -> list[dict]:
            service = _get_gmail_service(scout)
            resp = service.users().labels().list(userId="me").execute()
            return [{"id": lbl["id"], "name": lbl["name"]} for lbl in resp.get("labels", [])]

        return await asyncio.to_thread(_sync)

    async def stop_watch(self, scout) -> None:
        """Stop Gmail push notifications. Swallows errors (watch may be gone)."""
        if not scout.oauth_token_encrypted:
            return

        def _sync() -> None:
            service = _get_gmail_service(scout)
            service.users().stop(userId="me").execute()

        try:
            await asyncio.to_thread(_sync)
        except Exception:
            logger.exception("stop_watch failed for scout %s", scout.id)

(asyncio and logger are already imported in this file.)

  • Step 4: Run the tests
cd api
pytest tests/test_scout_connectors_gmail.py -v

Expected: all PASS (3 existing + 2 new).

  • Step 5: Commit
cd api
git add app/scouts/connectors/gmail.py tests/test_scout_connectors_gmail.py
git commit -m "feat(scouts): add GmailConnector list_labels + stop_watch"

Task 6: Gmail label-list + disconnect routes

Files:

  • Modify: api/app/api/routes/scouts.py

  • Test: api/tests/test_scout_cloud_crud.py (append)

  • Step 1: Write the failing tests

Append to api/tests/test_scout_cloud_crud.py:

@pytest.mark.asyncio
async def test_gmail_labels_route_returns_labels():
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
        created = (await client.post(
            "/api/v1/scouts/cloud",
            json={"name": "L", "provider": "gmail"},
            headers=_auth_headers(),
        )).json()
        sid = created["id"]

        with patch("app.api.routes.scouts.get_connector") as mock_get:
            from unittest.mock import AsyncMock
            mock_get.return_value.list_labels = AsyncMock(return_value=[{"id": "INBOX", "name": "INBOX"}])
            resp = await client.get(f"/api/v1/scouts/cloud/{sid}/gmail-labels", headers=_auth_headers())
    assert resp.status_code == 200
    assert resp.json() == [{"id": "INBOX", "name": "INBOX"}]


@pytest.mark.asyncio
async def test_gmail_disconnect_clears_token():
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
        created = (await client.post(
            "/api/v1/scouts/cloud",
            json={"name": "D", "provider": "gmail"},
            headers=_auth_headers(),
        )).json()
        sid = created["id"]
        # mark it connected directly in the DB
        async with _TestSessionLocal() as session:
            row = await session.get(CloudScoutConfig, sid)
            row.oauth_token_encrypted = "blob"
            row.gmail_address = "a@b.com"
            await session.commit()

        with patch("app.api.routes.scouts.get_connector") as mock_get:
            from unittest.mock import AsyncMock
            mock_get.return_value.stop_watch = AsyncMock()
            resp = await client.post(f"/api/v1/scouts/cloud/{sid}/gmail-disconnect", headers=_auth_headers())
    assert resp.status_code == 200
    body = resp.json()
    assert body["oauth_connected"] is False
    assert body["gmail_address"] is None
    assert body["enabled"] is False
  • Step 2: Run to confirm fail
cd api
pytest tests/test_scout_cloud_crud.py -k "labels or disconnect" -v

Expected: 404.

  • Step 3: Add the routes

Add to api/app/api/routes/scouts.py (after the cloud CRUD routes). Ensure from app.scouts.connectors.registry import get_connector is imported at module top (or import inside the handler):

@router.get("/cloud/{scout_id}/gmail-labels")
async def list_gmail_labels(
    scout_id: str,
    db: AsyncSession = Depends(get_session),
    current_user: UserProfile = Depends(get_current_user),
):
    scout = await db.get(CloudScoutConfig, scout_id)
    if scout is None or scout.user_id != current_user.id:
        raise HTTPException(status.HTTP_404_NOT_FOUND, "Scout not found")
    try:
        connector = get_connector("gmail")
    except KeyError:
        return []
    return await connector.list_labels(scout)


@router.post("/cloud/{scout_id}/gmail-disconnect", response_model=CloudScoutResponse)
async def disconnect_gmail(
    scout_id: str,
    db: AsyncSession = Depends(get_session),
    current_user: UserProfile = Depends(get_current_user),
):
    scout = await db.get(CloudScoutConfig, scout_id)
    if scout is None or scout.user_id != current_user.id:
        raise HTTPException(status.HTTP_404_NOT_FOUND, "Scout not found")
    try:
        connector = get_connector("gmail")
        await connector.stop_watch(scout)
    except KeyError:
        pass
    scout.oauth_token_encrypted = None
    scout.gmail_history_id = None
    scout.gmail_watch_expires_at = None
    scout.gmail_address = None
    scout.enabled = False
    await db.commit()
    await db.refresh(scout)
    return _to_cloud_response(scout)

Add from app.scouts.connectors.registry import get_connector to the imports if not present.

  • Step 4: Run the tests
cd api
pytest tests/test_scout_cloud_crud.py -v

Expected: all PASS (6 total).

  • Step 5: Commit
cd api
git add app/api/routes/scouts.py tests/test_scout_cloud_crud.py
git commit -m "feat(scouts): add gmail label-list + disconnect routes"

PHASE C — tRPC + shared types

Task 7: Shared CloudScoutConfig type fields

Files:

  • Modify: adiuvAI/src/shared/api-types.ts

  • Step 1: Extend CloudScoutConfigSchema

In adiuvAI/src/shared/api-types.ts, update the schema (currently ends with oauthConnected: z.boolean().optional()):

export const CloudScoutConfigSchema = z.object({
  id: z.string(),
  userId: z.string(),
  provider: z.enum(['gmail', 'teams', 'outlook']),
  name: z.string(),
  dataTypes: z.array(z.string()),
  promptTemplate: z.string(),
  scheduleCron: z.string(),
  filterConfig: z.object({
    labels: z.array(z.string()).optional(),
    senders: z.array(z.string()).optional(),
  }).optional(),
  autoTrashSpam: z.boolean().optional(),
  enabled: z.boolean(),
  lastRunAt: z.number().int().nullable().optional(),
  gmailAddress: z.string().nullable().optional(),
  oauthConnected: z.boolean().optional(),
  createdAt: z.number().int(),
  updatedAt: z.number().int(),
});
export type CloudScoutConfig = z.infer<typeof CloudScoutConfigSchema>;
  • Step 2: Typecheck
cd adiuvAI
npx tsc --noEmit 2>&1 | grep "api-types" || echo "no api-types errors"

Expected: no api-types errors.

  • Step 3: Commit
cd adiuvAI
git add src/shared/api-types.ts
git commit -m "feat(scouts): add cloud scout config fields to shared type"

Task 8: tRPC cloud router — input changes + new procedures

Files:

  • Modify: adiuvAI/src/main/router/index.ts (the scoutCloudRouter, lines 1155-1239)

  • Step 1: Update create input — schedule optional, add autoTrashSpam

Replace the create procedure's input schema:

  create: publicProcedure
    .input(z.object({
      name: z.string(),
      provider: z.enum(['gmail', 'teams', 'outlook']),
      dataTypes: z.array(z.string()).default([]),
      promptTemplate: z.string().default(''),
      scheduleCron: z.string().optional(),
      filterConfig: z.object({
        labels: z.array(z.string()).optional(),
        senders: z.array(z.string()).optional(),
      }).optional(),
      autoTrashSpam: z.boolean().optional(),
    }))
    .mutation(async ({ input }) => {
      try {
        const result = await getBackendClient().proxyPost<CloudScoutConfig>(
          '/api/v1/scouts/cloud',
          input as Record<string, unknown>,
        );
        return { data: result, error: null };
      } catch (err) {
        const msg = err instanceof Error ? err.message : 'Failed to create cloud scout';
        return { data: null, error: msg };
      }
    }),
  • Step 2: Update update input — add autoTrashSpam, type filterConfig
  update: publicProcedure
    .input(z.object({
      id: z.string(),
      name: z.string().optional(),
      dataTypes: z.array(z.string()).optional(),
      promptTemplate: z.string().optional(),
      scheduleCron: z.string().optional(),
      filterConfig: z.object({
        labels: z.array(z.string()).optional(),
        senders: z.array(z.string()).optional(),
      }).optional(),
      autoTrashSpam: z.boolean().optional(),
      enabled: z.boolean().optional(),
    }))
    .mutation(async ({ input }) => {
      const { id, ...updates } = input;
      try {
        const result = await getBackendClient().proxyPut<CloudScoutConfig>(
          `/api/v1/scouts/cloud/${id}`,
          updates as Record<string, unknown>,
        );
        return { data: result, error: null };
      } catch (err) {
        const msg = err instanceof Error ? err.message : 'Failed to update cloud scout';
        return { data: null, error: msg };
      }
    }),
  • Step 3: Add gmailLabels + disconnectGmail procedures

Add inside scoutCloudRouter, after completeGmailOAuth:

  gmailLabels: publicProcedure
    .input(z.object({ scoutId: z.string() }))
    .query(async ({ input }) => {
      try {
        return await getBackendClient().proxyGet<{ id: string; name: string }[]>(
          `/api/v1/scouts/cloud/${input.scoutId}/gmail-labels`,
        );
      } catch (err) {
        console.error('[Scout] gmailLabels error:', err instanceof Error ? err.message : err);
        return [];
      }
    }),

  disconnectGmail: publicProcedure
    .input(z.object({ scoutId: z.string() }))
    .mutation(async ({ input }) => {
      try {
        const result = await getBackendClient().proxyPost<CloudScoutConfig>(
          `/api/v1/scouts/cloud/${input.scoutId}/gmail-disconnect`,
          {},
        );
        return { data: result, error: null };
      } catch (err) {
        const msg = err instanceof Error ? err.message : 'Failed to disconnect Gmail';
        return { data: null, error: msg };
      }
    }),
  • Step 4: Typecheck
cd adiuvAI
npx tsc --noEmit 2>&1 | grep "router/index" || echo "no router errors"

Expected: no router errors.

  • Step 5: Commit
cd adiuvAI
git add src/main/router/index.ts
git commit -m "feat(scouts): tRPC cloud input changes + gmailLabels + disconnectGmail"

PHASE D — Electron UI

Task 9: Extract LocalScoutCreationFlow from the stepper

Files:

  • Create: adiuvAI/src/renderer/components/settings/LocalScoutCreationFlow.tsx
  • Modify: adiuvAI/src/renderer/components/settings/InlineScoutCreationStepper.tsx

Context: This is a pure refactor — move the current local 3-step body (config + review for local_directory) into its own component without behavior change. The stepper keeps Step 1 (template pick) and delegates. This isolates the local flow before the cloud flow is added, keeping each file focused.

  • Step 1: Create LocalScoutCreationFlow.tsx

Move the local-specific logic (directory picker, data-types, schedule, prompt builder, review, create-local mutation) out of InlineScoutCreationStepper.tsx into a new component:

import { useState } from 'react';
import { FolderOpen, X, Sparkles } from 'lucide-react';
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import {
  Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import type { AgentCatalogItem } from '../../../shared/api-types';
import { DATA_TYPE_CONFIG, SCHEDULE_OPTIONS } from './types';
import { PromptBuilderChat } from './PromptBuilderChat';

export function LocalScoutCreationFlow({
  template,
  onCancel,
  onCreated,
}: {
  template: AgentCatalogItem;
  onCancel: () => void;
  onCreated: () => void;
}) {
  const createLocalMutation = trpc.scout.local.create.useMutation();
  const { notify, notifyError } = useNotify();

  const [step, setStep] = useState<2 | 3>(2);
  const [name, setName] = useState(template.name);
  const [directory, setDirectory] = useState('');
  const [promptDialogOpen, setPromptDialogOpen] = useState(false);
  const [dataTypes, setDataTypes] = useState<string[]>((template.supportedDataTypes ?? []).slice(0, 2));
  const [schedule, setSchedule] = useState('0 * * * *');
  const [promptTemplate, setPromptTemplate] = useState('');
  const [scoutConfig, setScoutConfig] = useState<Record<string, unknown> | null>(null);
  const [error, setError] = useState('');

  const isSubmitting = createLocalMutation.isPending;

  async function pickDirectory() {
    try {
      const result = await window.electronDialog.showOpenDialog({
        properties: ['openDirectory'],
        title: 'Select directory for scout to watch',
      });
      if (!result.canceled && result.filePaths.length > 0) setDirectory(result.filePaths[0]!);
    } catch { /* noop */ }
  }

  function toggleDataType(type: string) {
    setDataTypes(prev => prev.includes(type) ? prev.filter(t => t !== type) : [...prev, type]);
  }

  function nextFromConfig() {
    if (!name.trim()) { setError('Scout name is required.'); return; }
    if (!directory) { setError('Select a directory.'); return; }
    if (dataTypes.length === 0) { setError('Select at least one data type.'); return; }
    setError(''); setStep(3);
  }

  function handleCreate() {
    setError('');
    createLocalMutation.mutate(
      { name, directory, dataTypes, scheduleCron: schedule, agentConfig: scoutConfig ?? null },
      {
        onSuccess: () => { notify('success', 'toast.scout.created'); onCreated(); },
        onError: (err) => { notifyError('toast.scout.createError', err); setError(err.message); },
      },
    );
  }

  const unlocked = !!directory && dataTypes.length > 0;

  return (
    <div className="flex flex-col gap-7 w-full">
      {step === 2 && (
        <>
          <div className="pb-1">
            <p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 mb-2">Step 2 of 3</p>
            <h2 className="text-3xl font-semibold tracking-tight leading-tight">
              Configure<br />
              <input
                value={name}
                onChange={(e) => setName(e.target.value)}
                className="text-muted-foreground/50 bg-transparent outline-none border-none w-full placeholder:text-muted-foreground/30 caret-primary"
                placeholder="scout name."
                spellCheck={false}
              />
            </h2>
            <p className="text-sm text-muted-foreground mt-3 leading-relaxed">Point it at your files, pick what to extract, and set the run schedule.</p>
          </div>

          <div>
            <label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 block mb-2">Directory</label>
            {directory && (
              <div className="flex items-center gap-2 bg-muted/40 rounded-lg px-3 py-2 text-xs font-mono mb-2">
                <FolderOpen className="size-3 text-muted-foreground shrink-0" />
                <span className="flex-1 truncate">{directory}</span>
                <button onClick={() => setDirectory('')} className="text-muted-foreground hover:text-foreground transition-colors">
                  <X className="size-3" />
                </button>
              </div>
            )}
            <Button size="sm" variant="outline" onClick={pickDirectory}>
              <FolderOpen className="size-3.5 mr-1.5" />
              {directory ? 'Change directory' : 'Select directory'}
            </Button>
          </div>

          <div>
            <label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 block mb-3">What to extract</label>
            <div className="flex flex-wrap gap-2">
              {DATA_TYPE_CONFIG.map(({ value, label, Icon }) => {
                const active = dataTypes.includes(value);
                return (
                  <button key={value} onClick={() => toggleDataType(value)}
                    className={cn(
                      'inline-flex items-center gap-2 px-3.5 py-2 rounded-xl border text-sm font-medium transition-all duration-150',
                      active ? 'bg-foreground text-background border-foreground'
                             : 'bg-background text-muted-foreground border-border/60 hover:border-border hover:text-foreground',
                    )}>
                    <Icon className="size-3.5" />
                    {label}
                  </button>
                );
              })}
            </div>
          </div>

          <div>
            <label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 block mb-2">Batch interval</label>
            <Select value={schedule} onValueChange={setSchedule}>
              <SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
              <SelectContent>
                {SCHEDULE_OPTIONS.map(opt => <SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>)}
              </SelectContent>
            </Select>
          </div>

          <div>
            <Button variant={promptTemplate || scoutConfig ? 'outline' : 'default'} size="sm" disabled={!unlocked} onClick={() => setPromptDialogOpen(true)}>
              <Sparkles className="size-3.5 mr-1.5" />
              {promptTemplate || scoutConfig ? 'Edit extraction prompt' : 'Build extraction prompt'}
            </Button>
            <Dialog open={promptDialogOpen} onOpenChange={setPromptDialogOpen}>
              <DialogContent showCloseButton={false} className="w-[95vw] max-w-[1100px] h-[75vh] max-h-[780px] flex flex-col gap-0 p-0 overflow-hidden">
                <div className="flex-1 min-h-0 overflow-hidden flex flex-col">
                  <PromptBuilderChat
                    autoStart
                    agentType="local_directory"
                    dataTypes={dataTypes}
                    directory={directory}
                    onPromptUpdate={(p) => setPromptTemplate(p)}
                    onConfigUpdate={(c) => setScoutConfig(c)}
                  />
                </div>
                <div className="flex justify-end gap-2 px-5 py-4 border-t shrink-0">
                  <Button variant="outline" size="sm" onClick={() => setPromptDialogOpen(false)}>Close</Button>
                  <Button size="sm" onClick={() => setPromptDialogOpen(false)}>Confirm</Button>
                </div>
              </DialogContent>
            </Dialog>
          </div>
        </>
      )}

      {step === 3 && (
        <div className="flex flex-col gap-5">
          <div className="pb-1">
            <p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 mb-2">Step 3 of 3</p>
            <h2 className="text-3xl font-semibold tracking-tight leading-tight">
              Review and<br />
              <span className="text-muted-foreground/50">create your scout.</span>
            </h2>
            <p className="text-sm text-muted-foreground mt-3 leading-relaxed">Everything looks good? Hit create and your scout will start running on schedule.</p>
          </div>
          <Card className="rounded-xl gap-0 py-0 shadow-none border-border/70">
            <CardContent className="p-5 flex flex-col gap-4">
              <div className="space-y-1">
                <p className="text-xs text-muted-foreground">Template</p>
                <p className="text-sm font-medium">{template.name}</p>
              </div>
              <div className="border rounded-lg px-4 py-3 grid gap-2 text-sm bg-background">
                <p><span className="text-muted-foreground">Name:</span> {name}</p>
                <p><span className="text-muted-foreground">Data types:</span> {dataTypes.join(', ') || 'None'}</p>
                <p><span className="text-muted-foreground">Schedule:</span> {SCHEDULE_OPTIONS.find(s => s.value === schedule)?.label ?? schedule}</p>
                <p><span className="text-muted-foreground">Directory:</span> {directory}</p>
                {scoutConfig && <p><span className="text-muted-foreground">Extraction config:</span> Added</p>}
              </div>
            </CardContent>
          </Card>
        </div>
      )}

      {error && <p className="text-xs text-destructive">{error}</p>}

      <div className="flex items-center justify-between pt-1">
        <Button variant="outline" size="sm" onClick={() => { setError(''); step === 2 ? onCancel() : setStep(2); }}>
          {step === 2 ? 'Cancel' : 'Back'}
        </Button>
        {step === 2 && <Button size="sm" onClick={nextFromConfig}>Next</Button>}
        {step === 3 && <Button size="sm" onClick={handleCreate} disabled={isSubmitting}>Create scout now</Button>}
      </div>
    </div>
  );
}
  • Step 2: Slim down InlineScoutCreationStepper.tsx to a router

Replace the whole file body with the shared template-pick step that delegates:

import { useState } from 'react';
import { Bot } from 'lucide-react';
import type { AgentCatalogItem } from '../../../shared/api-types';
import { TemplateSelectCard } from './TemplateSelectCard';
import { LocalScoutCreationFlow } from './LocalScoutCreationFlow';
import { CloudScoutCreationFlow } from './CloudScoutCreationFlow';

export function InlineScoutCreationStepper({
  catalog,
  isLoadingCatalog,
  onCancel,
  onCreated,
}: {
  catalog: AgentCatalogItem[];
  isLoadingCatalog: boolean;
  onCancel: () => void;
  onCreated: () => void;
}) {
  const [selectedTemplate, setSelectedTemplate] = useState<AgentCatalogItem | null>(null);

  if (selectedTemplate) {
    return selectedTemplate.type === 'local_directory'
      ? <LocalScoutCreationFlow template={selectedTemplate} onCancel={() => setSelectedTemplate(null)} onCreated={onCreated} />
      : <CloudScoutCreationFlow template={selectedTemplate} onCancel={() => setSelectedTemplate(null)} onCreated={onCreated} />;
  }

  return (
    <div className="flex flex-col gap-5 w-full">
      <div className="pb-1">
        <p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 mb-2">Step 1 of 3</p>
        <h2 className="text-3xl font-semibold tracking-tight leading-tight">
          Choose your<br />
          <span className="text-muted-foreground/50">starting template.</span>
        </h2>
        <p className="text-sm text-muted-foreground mt-3 leading-relaxed">Pick a starting point  you can customize everything before the scout goes live.</p>
      </div>

      {isLoadingCatalog && (
        <div className="rounded-lg border border-dashed px-4 py-10 text-sm text-muted-foreground text-center bg-muted/20">Loading templates...</div>
      )}

      {!isLoadingCatalog && catalog.length === 0 && (
        <div className="rounded-xl border border-dashed px-6 py-10 text-center">
          <Bot className="size-8 mx-auto mb-3 text-muted-foreground/50" />
          <p className="text-sm text-muted-foreground">No templates available yet. Add your server URL in Account settings, then try again.</p>
        </div>
      )}

      <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
        {catalog.map((item) => (
          <TemplateSelectCard key={item.id} item={item} selected={false} onSelect={() => setSelectedTemplate(item)} />
        ))}
      </div>

      <div className="flex items-center justify-between pt-1">
        <Button variant="outline" size="sm" onClick={onCancel}>Cancel</Button>
      </div>
    </div>
  );
}

Add the missing Button import: import { Button } from '@/components/ui/button';.

  • Step 3: Typecheck (CloudScoutCreationFlow not yet created — expect that one import error only)
cd adiuvAI
npx tsc --noEmit 2>&1 | grep -iE "InlineScoutCreationStepper|LocalScoutCreationFlow" | grep -v "CloudScoutCreationFlow" || echo "only the expected missing-CloudFlow import remains"

Expected: only the CloudScoutCreationFlow missing-module error (resolved in Task 10).

  • Step 4: Commit
cd adiuvAI
git add src/renderer/components/settings/LocalScoutCreationFlow.tsx src/renderer/components/settings/InlineScoutCreationStepper.tsx
git commit -m "refactor(scouts): extract LocalScoutCreationFlow, stepper becomes router"

Task 10: CloudScoutCreationFlow (Gmail slim flow)

Files:

  • Create: adiuvAI/src/renderer/components/settings/CloudScoutCreationFlow.tsx

Context: Two steps. Step A: name + focus + auto-trash + Connect Gmail (creates dormant scout, runs OAuth). Step B (after connect): label multi-select + sender chips + Finish. The OAuth deep-link callback is delivered via window.electronAI.onScoutGmailOAuthCallback (already wired in Phase 3, used by CloudScoutConfigPanel).

  • Step 1: Create the component
import { useEffect, useRef, useState } from 'react';
import { Mail, X, Plus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Switch } from '@/components/ui/switch';
import type { AgentCatalogItem } from '../../../shared/api-types';

export function CloudScoutCreationFlow({
  template,
  onCancel,
  onCreated,
}: {
  template: AgentCatalogItem;
  onCancel: () => void;
  onCreated: () => void;
}) {
  const { t } = useTranslation();
  const { notify, notifyError } = useNotify();

  const createMutation = trpc.scout.cloud.create.useMutation();
  const updateMutation = trpc.scout.cloud.update.useMutation();
  const startOAuth = trpc.scout.cloud.startGmailOAuth.useMutation();
  const completeOAuth = trpc.scout.cloud.completeGmailOAuth.useMutation();

  const [step, setStep] = useState<'basics' | 'filter'>('basics');
  const [name, setName] = useState(template.name);
  const [focus, setFocus] = useState('');
  const [autoTrash, setAutoTrash] = useState(false);
  const [scoutId, setScoutId] = useState<string | null>(null);
  const [connecting, setConnecting] = useState(false);
  const [error, setError] = useState('');

  const [labels, setLabels] = useState<string[]>([]);
  const [senderInput, setSenderInput] = useState('');
  const [senders, setSenders] = useState<string[]>([]);

  const labelsQuery = trpc.scout.cloud.gmailLabels.useQuery(
    { scoutId: scoutId ?? '' },
    { enabled: step === 'filter' && !!scoutId },
  );

  const scoutIdRef = useRef<string | null>(null);
  scoutIdRef.current = scoutId;

  // OAuth deep-link callback → complete, then advance to filter step.
  useEffect(() => {
    const electronAI = (window as unknown as { electronAI?: { onScoutGmailOAuthCallback?: (cb: (d: { code: string; state: string }) => void) => (() => void) } }).electronAI;
    if (!electronAI?.onScoutGmailOAuthCallback) return;
    const off = electronAI.onScoutGmailOAuthCallback(async ({ code, state }) => {
      try {
        await completeOAuth.mutateAsync({ code, state });
        notify('success', 'toast.scout.gmailConnected');
        setConnecting(false);
        setStep('filter');
      } catch (err) {
        setConnecting(false);
        notifyError('toast.scout.createError', err instanceof Error ? err : new Error(String(err)));
      }
    });
    return () => { off?.(); };
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  async function handleConnect() {
    if (!name.trim()) { setError(t('scouts.nameRequired')); return; }
    setError('');
    setConnecting(true);
    try {
      const res = await createMutation.mutateAsync({
        name,
        provider: 'gmail',
        dataTypes: [],
        promptTemplate: focus,
        autoTrashSpam: autoTrash,
        filterConfig: {},
      });
      if (res.error || !res.data) throw new Error(res.error ?? 'create failed');
      setScoutId(res.data.id);
      await startOAuth.mutateAsync({ scoutId: res.data.id });
      // Wait for the deep-link callback (handled in the effect).
    } catch (err) {
      setConnecting(false);
      setError(err instanceof Error ? err.message : 'Failed to connect');
    }
  }

  function toggleLabel(id: string) {
    setLabels(prev => prev.includes(id) ? prev.filter(l => l !== id) : [...prev, id]);
  }

  function addSender() {
    const v = senderInput.trim();
    if (v && !senders.includes(v)) setSenders(prev => [...prev, v]);
    setSenderInput('');
  }

  async function handleFinish() {
    if (!scoutId) return;
    try {
      await updateMutation.mutateAsync({
        id: scoutId,
        filterConfig: { labels, senders },
      });
      notify('success', 'toast.scout.created');
      onCreated();
    } catch (err) {
      notifyError('toast.scout.updateError', err instanceof Error ? err : new Error(String(err)));
    }
  }

  if (step === 'basics') {
    return (
      <div className="flex flex-col gap-7 w-full">
        <div className="pb-1">
          <p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 mb-2">Step 2 of 3</p>
          <h2 className="text-3xl font-semibold tracking-tight leading-tight">
            Configure<br />
            <input
              value={name}
              onChange={(e) => setName(e.target.value)}
              className="text-muted-foreground/50 bg-transparent outline-none border-none w-full placeholder:text-muted-foreground/30 caret-primary"
              placeholder={t('scouts.namePlaceholder')}
              spellCheck={false}
            />
          </h2>
          <p className="text-sm text-muted-foreground mt-3 leading-relaxed">{t('scouts.gmailBasicsHint')}</p>
        </div>

        <div>
          <label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 block mb-2">{t('scouts.focusLabel')}</label>
          <Input value={focus} onChange={(e) => setFocus(e.target.value)} placeholder={t('scouts.focusPlaceholder')} />
        </div>

        <div className="flex items-center justify-between rounded-lg border px-4 py-3">
          <div>
            <p className="text-sm font-medium">{t('scouts.autoTrashSpam')}</p>
            <p className="text-xs text-muted-foreground">{t('scouts.autoTrashHint')}</p>
          </div>
          <Switch checked={autoTrash} onCheckedChange={setAutoTrash} />
        </div>

        {error && <p className="text-xs text-destructive">{error}</p>}

        <div className="flex items-center justify-between pt-1">
          <Button variant="outline" size="sm" onClick={onCancel}>Cancel</Button>
          <Button size="sm" onClick={handleConnect} disabled={connecting}>
            <Mail className="size-3.5 mr-1.5" />
            {connecting ? t('scouts.connecting') : t('scouts.connectGmail')}
          </Button>
        </div>
      </div>
    );
  }

  // step === 'filter'
  return (
    <div className="flex flex-col gap-7 w-full">
      <div className="pb-1">
        <p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 mb-2">Step 3 of 3</p>
        <h2 className="text-3xl font-semibold tracking-tight leading-tight">
          Narrow the<br />
          <span className="text-muted-foreground/50">emails it watches.</span>
        </h2>
        <p className="text-sm text-muted-foreground mt-3 leading-relaxed">{t('scouts.filterHint')}</p>
      </div>

      <div>
        <label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 block mb-2">{t('scouts.filterLabels')}</label>
        {labelsQuery.isPending && <p className="text-xs text-muted-foreground">Loading labels</p>}
        <div className="flex flex-wrap gap-3">
          {(labelsQuery.data ?? []).map(lbl => (
            <label key={lbl.id} className="flex items-center gap-2 text-sm cursor-pointer">
              <Checkbox checked={labels.includes(lbl.id)} onCheckedChange={() => toggleLabel(lbl.id)} />
              <span>{lbl.name}</span>
            </label>
          ))}
        </div>
      </div>

      <div>
        <label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 block mb-2">{t('scouts.filterSenders')}</label>
        <div className="flex gap-2">
          <Input
            value={senderInput}
            onChange={(e) => setSenderInput(e.target.value)}
            onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addSender(); } }}
            placeholder={t('scouts.filterSendersPlaceholder')}
          />
          <Button size="sm" variant="outline" onClick={addSender}><Plus className="size-3.5" /></Button>
        </div>
        {senders.length > 0 && (
          <div className="flex flex-wrap gap-2 mt-2">
            {senders.map(s => (
              <span key={s} className="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-1 text-xs">
                {s}
                <button onClick={() => setSenders(prev => prev.filter(x => x !== s))}><X className="size-3" /></button>
              </span>
            ))}
          </div>
        )}
        <p className="text-xs text-muted-foreground mt-2">{t('scouts.watchAllInbox')}</p>
      </div>

      <div className="flex items-center justify-end gap-2 pt-1">
        <Button variant="outline" size="sm" onClick={handleFinish}>{t('scouts.skipFilter')}</Button>
        <Button size="sm" onClick={handleFinish} disabled={updateMutation.isPending}>{t('scouts.finish')}</Button>
      </div>
    </div>
  );
}

If Switch isn't an existing shadcn component, add it: cd adiuvAI && npx shadcn@latest add switch. Verify first with ls src/renderer/components/ui/switch.tsx.

  • Step 2: Typecheck
cd adiuvAI
npx tsc --noEmit 2>&1 | grep -iE "CloudScoutCreationFlow|InlineScoutCreationStepper" || echo "no creation-flow errors"

Expected: no creation-flow errors (i18n keys are runtime, not typechecked).

  • Step 3: Commit
cd adiuvAI
git add src/renderer/components/settings/CloudScoutCreationFlow.tsx src/renderer/components/ui/switch.tsx
git commit -m "feat(scouts): add CloudScoutCreationFlow Gmail slim flow"

Task 11: Rewrite CloudScoutConfigPanel for parity

Files:

  • Modify: adiuvAI/src/renderer/components/settings/CloudScoutConfigPanel.tsx

  • Step 1: Replace the panel body

import { useEffect, useState } from 'react';
import { Mail, X, Plus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Switch } from '@/components/ui/switch';
import type { CloudScoutConfig } from '../../../shared/api-types';

export function CloudScoutConfigPanel({
  scout,
}: {
  scout: CloudScoutConfig & { scoutType: 'cloud' };
  onOpenJourney: () => void;
}) {
  const { t } = useTranslation();
  const utils = trpc.useUtils();
  const updateMutation = trpc.scout.cloud.update.useMutation();
  const startOAuth = trpc.scout.cloud.startGmailOAuth.useMutation();
  const completeOAuth = trpc.scout.cloud.completeGmailOAuth.useMutation();
  const disconnect = trpc.scout.cloud.disconnectGmail.useMutation();
  const { notify, notifyError } = useNotify();

  const [focus, setFocus] = useState(scout.promptTemplate ?? '');
  const [autoTrash, setAutoTrash] = useState(scout.autoTrashSpam ?? false);
  const [labels, setLabels] = useState<string[]>(scout.filterConfig?.labels ?? []);
  const [senders, setSenders] = useState<string[]>(scout.filterConfig?.senders ?? []);
  const [senderInput, setSenderInput] = useState('');

  const labelsQuery = trpc.scout.cloud.gmailLabels.useQuery(
    { scoutId: scout.id },
    { enabled: !!scout.oauthConnected },
  );

  useEffect(() => {
    const electronAI = (window as unknown as { electronAI?: { onScoutGmailOAuthCallback?: (cb: (d: { code: string; state: string }) => void) => (() => void) } }).electronAI;
    if (!electronAI?.onScoutGmailOAuthCallback) return;
    const off = electronAI.onScoutGmailOAuthCallback(async ({ code, state }) => {
      try {
        await completeOAuth.mutateAsync({ code, state });
        notify('success', 'toast.scout.gmailConnected');
        void utils.scout.cloud.list.invalidate();
      } catch (err) {
        notifyError('toast.scout.updateError', err instanceof Error ? err : new Error(String(err)));
      }
    });
    return () => { off?.(); };
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  function toggleLabel(id: string) {
    setLabels(prev => prev.includes(id) ? prev.filter(l => l !== id) : [...prev, id]);
  }
  function addSender() {
    const v = senderInput.trim();
    if (v && !senders.includes(v)) setSenders(prev => [...prev, v]);
    setSenderInput('');
  }

  function handleSave() {
    updateMutation.mutate(
      { id: scout.id, promptTemplate: focus, autoTrashSpam: autoTrash, filterConfig: { labels, senders } },
      {
        onSuccess: () => { notify('success', 'toast.scout.updated'); void utils.scout.cloud.list.invalidate(); },
        onError: (err) => notifyError('toast.scout.updateError', err),
      },
    );
  }

  function handleDisconnect() {
    disconnect.mutate({ scoutId: scout.id }, {
      onSuccess: () => { notify('warning', 'toast.scout.updated'); void utils.scout.cloud.list.invalidate(); },
      onError: (err) => notifyError('toast.scout.updateError', err),
    });
  }

  const connected = !!scout.oauthConnected;

  return (
    <div className="flex flex-col gap-4">
      <div className="flex items-center gap-2">
        <Badge variant="outline" className="capitalize">{scout.provider}</Badge>
      </div>

      {/* Connection status */}
      {connected ? (
        <div className="flex items-center gap-3 rounded-md border px-3 py-2">
          <Mail className="size-4 text-muted-foreground" />
          <span className="text-xs flex-1">{t('scouts.connectedAs')} <span className="font-medium">{scout.gmailAddress ?? '—'}</span></span>
          <Button size="sm" variant="outline" onClick={() => startOAuth.mutate({ scoutId: scout.id })}>{t('scouts.reconnect')}</Button>
          <Button size="sm" variant="ghost" onClick={handleDisconnect} disabled={disconnect.isPending}>{t('scouts.disconnect')}</Button>
        </div>
      ) : (
        <div className="flex items-center gap-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 dark:border-amber-800 dark:bg-amber-950/30">
          <span className="text-xs text-amber-700 dark:text-amber-400 flex-1">{t('scouts.gmailAccessRequired')}</span>
          <Button size="sm" variant="outline" onClick={() => startOAuth.mutate({ scoutId: scout.id })} disabled={startOAuth.isPending}>
            {t('scouts.connectGmail')}
          </Button>
        </div>
      )}

      {/* Focus */}
      <div>
        <label className="text-xs font-medium text-muted-foreground uppercase tracking-wide block mb-2">{t('scouts.focusLabel')}</label>
        <Input value={focus} onChange={(e) => setFocus(e.target.value)} placeholder={t('scouts.focusPlaceholder')} />
      </div>

      {/* Filter — labels */}
      {connected && (
        <div>
          <label className="text-xs font-medium text-muted-foreground uppercase tracking-wide block mb-2">{t('scouts.filterLabels')}</label>
          {labelsQuery.isPending && <p className="text-xs text-muted-foreground">Loading labels</p>}
          <div className="flex flex-wrap gap-3">
            {(labelsQuery.data ?? []).map(lbl => (
              <label key={lbl.id} className="flex items-center gap-2 text-sm cursor-pointer">
                <Checkbox checked={labels.includes(lbl.id)} onCheckedChange={() => toggleLabel(lbl.id)} />
                <span>{lbl.name}</span>
              </label>
            ))}
          </div>
        </div>
      )}

      {/* Filter — senders */}
      <div>
        <label className="text-xs font-medium text-muted-foreground uppercase tracking-wide block mb-2">{t('scouts.filterSenders')}</label>
        <div className="flex gap-2">
          <Input value={senderInput} onChange={(e) => setSenderInput(e.target.value)}
            onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addSender(); } }}
            placeholder={t('scouts.filterSendersPlaceholder')} />
          <Button size="sm" variant="outline" onClick={addSender}><Plus className="size-3.5" /></Button>
        </div>
        {senders.length > 0 && (
          <div className="flex flex-wrap gap-2 mt-2">
            {senders.map(s => (
              <span key={s} className="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-1 text-xs">
                {s}<button onClick={() => setSenders(prev => prev.filter(x => x !== s))}><X className="size-3" /></button>
              </span>
            ))}
          </div>
        )}
      </div>

      {/* Auto-trash */}
      <div className="flex items-center justify-between rounded-lg border px-4 py-3">
        <div>
          <p className="text-sm font-medium">{t('scouts.autoTrashSpam')}</p>
          <p className="text-xs text-muted-foreground">{t('scouts.autoTrashHint')}</p>
        </div>
        <Switch checked={autoTrash} onCheckedChange={setAutoTrash} />
      </div>

      <div className="flex items-center justify-end pt-1">
        <Button size="sm" onClick={handleSave} disabled={updateMutation.isPending}>{t('common.save')}</Button>
      </div>
    </div>
  );
}

Note: onOpenJourney is kept in the prop type for compatibility with ScoutRow but no longer used — verify ScoutRow still passes it; if ScoutRow errors on an unused prop, leave the prop in the signature (it's accepted, just ignored). If TypeScript complains about the unused prop, prefix with _: onOpenJourney: _onOpenJourney. Simplest: keep the prop in the destructure but don't reference it — TS won't error on an unused destructured prop.

  • Step 2: Typecheck
cd adiuvAI
npx tsc --noEmit 2>&1 | grep -i "CloudScoutConfigPanel" || echo "no panel errors"

Expected: no panel errors.

  • Step 3: Commit
cd adiuvAI
git add src/renderer/components/settings/CloudScoutConfigPanel.tsx
git commit -m "refactor(scouts): rewrite CloudScoutConfigPanel for slim-model parity"

Task 12: Disable Teams/Outlook template cards

Files:

  • Modify: adiuvAI/src/renderer/components/settings/TemplateSelectCard.tsx

  • Step 1: Read the card to find the catalog-item type discriminator

cd adiuvAI
cat src/renderer/components/settings/TemplateSelectCard.tsx

Note the item prop shape and how onSelect is wired. Cloud non-Gmail providers are identified by item.provider === 'teams' || item.provider === 'outlook' (or item.type — confirm from the file).

  • Step 2: Gate selection + add "coming soon"

In the card component, compute:

const comingSoon = item.provider === 'teams' || item.provider === 'outlook';

Apply to the root element: when comingSoon, add opacity-50 pointer-events-none to the className and render a small badge {comingSoon && <span className="text-[10px] uppercase tracking-wide text-muted-foreground">{t('scouts.comingSoon')}</span>} near the title. Guard onSelect: onClick={comingSoon ? undefined : onSelect}.

Use useTranslation if not already imported. Confirm the actual field (item.provider vs item.type) from Step 1 and use the correct one.

  • Step 3: Typecheck
cd adiuvAI
npx tsc --noEmit 2>&1 | grep -i "TemplateSelectCard" || echo "no card errors"

Expected: no card errors.

  • Step 4: Commit
cd adiuvAI
git add src/renderer/components/settings/TemplateSelectCard.tsx
git commit -m "feat(scouts): disable Teams/Outlook template cards (coming soon)"

Task 13: i18n keys (5 languages)

Files:

  • Modify: adiuvAI/src/renderer/locales/{en,it,es,fr,de}/translation.json

  • Step 1: Add the keys under the scouts namespace in each file

English (en/translation.json), add to the scouts object:

"nameRequired": "Scout name is required.",
"namePlaceholder": "scout name.",
"gmailBasicsHint": "Name it, tell it what to focus on, then connect your Gmail.",
"focusLabel": "What should this scout watch for?",
"focusPlaceholder": "e.g. client requests, invoices, project updates",
"autoTrashSpam": "Auto-trash spam",
"autoTrashHint": "Move emails it judges as spam to Gmail Trash (recoverable for 30 days).",
"connecting": "Connecting…",
"filterHint": "Leave empty to watch your whole inbox, or narrow by label and sender.",
"filterLabels": "Labels",
"filterSenders": "Senders",
"filterSendersPlaceholder": "alice@example.com or @client.co",
"watchAllInbox": "No filter = watch the whole inbox.",
"skipFilter": "Skip",
"finish": "Finish",
"connectedAs": "Connected as",
"reconnect": "Reconnect",
"disconnect": "Disconnect",
"gmailAccessRequired": "Gmail access required to start receiving email suggestions.",
"comingSoon": "Coming soon"

Italian (it):

"nameRequired": "Il nome dello scout è obbligatorio.",
"namePlaceholder": "nome scout.",
"gmailBasicsHint": "Dagli un nome, indica su cosa concentrarsi, poi connetti Gmail.",
"focusLabel": "Su cosa deve vigilare questo scout?",
"focusPlaceholder": "es. richieste clienti, fatture, aggiornamenti progetto",
"autoTrashSpam": "Cestina spam automaticamente",
"autoTrashHint": "Sposta le email giudicate spam nel Cestino di Gmail (recuperabili per 30 giorni).",
"connecting": "Connessione…",
"filterHint": "Lascia vuoto per monitorare tutta la casella, o restringi per etichetta e mittente.",
"filterLabels": "Etichette",
"filterSenders": "Mittenti",
"filterSendersPlaceholder": "alice@example.com oppure @client.co",
"watchAllInbox": "Nessun filtro = monitora tutta la casella.",
"skipFilter": "Salta",
"finish": "Fine",
"connectedAs": "Connesso come",
"reconnect": "Riconnetti",
"disconnect": "Disconnetti",
"gmailAccessRequired": "Accesso a Gmail necessario per ricevere suggerimenti email.",
"comingSoon": "Prossimamente"

Spanish (es):

"nameRequired": "El nombre del scout es obligatorio.",
"namePlaceholder": "nombre del scout.",
"gmailBasicsHint": "Ponle nombre, indícale en qué centrarse y conecta tu Gmail.",
"focusLabel": "¿Qué debe vigilar este scout?",
"focusPlaceholder": "p. ej. solicitudes de clientes, facturas, novedades de proyectos",
"autoTrashSpam": "Enviar spam a la papelera automáticamente",
"autoTrashHint": "Mueve los correos que considere spam a la Papelera de Gmail (recuperables 30 días).",
"connecting": "Conectando…",
"filterHint": "Déjalo vacío para vigilar toda la bandeja, o filtra por etiqueta y remitente.",
"filterLabels": "Etiquetas",
"filterSenders": "Remitentes",
"filterSendersPlaceholder": "alice@example.com o @client.co",
"watchAllInbox": "Sin filtro = vigila toda la bandeja de entrada.",
"skipFilter": "Omitir",
"finish": "Finalizar",
"connectedAs": "Conectado como",
"reconnect": "Reconectar",
"disconnect": "Desconectar",
"gmailAccessRequired": "Se requiere acceso a Gmail para recibir sugerencias de correo.",
"comingSoon": "Próximamente"

French (fr):

"nameRequired": "Le nom du scout est requis.",
"namePlaceholder": "nom du scout.",
"gmailBasicsHint": "Nommez-le, indiquez sur quoi se concentrer, puis connectez votre Gmail.",
"focusLabel": "Que doit surveiller ce scout ?",
"focusPlaceholder": "ex. demandes clients, factures, mises à jour de projet",
"autoTrashSpam": "Mettre le spam à la corbeille automatiquement",
"autoTrashHint": "Déplace les e-mails jugés indésirables vers la corbeille Gmail (récupérables 30 jours).",
"connecting": "Connexion…",
"filterHint": "Laissez vide pour surveiller toute la boîte, ou filtrez par libellé et expéditeur.",
"filterLabels": "Libellés",
"filterSenders": "Expéditeurs",
"filterSendersPlaceholder": "alice@example.com ou @client.co",
"watchAllInbox": "Aucun filtre = surveille toute la boîte de réception.",
"skipFilter": "Ignorer",
"finish": "Terminer",
"connectedAs": "Connecté en tant que",
"reconnect": "Reconnecter",
"disconnect": "Déconnecter",
"gmailAccessRequired": "Accès Gmail requis pour recevoir des suggestions d'e-mails.",
"comingSoon": "Bientôt disponible"

German (de):

"nameRequired": "Scout-Name ist erforderlich.",
"namePlaceholder": "Scout-Name.",
"gmailBasicsHint": "Benenne ihn, lege den Fokus fest und verbinde dann dein Gmail.",
"focusLabel": "Worauf soll dieser Scout achten?",
"focusPlaceholder": "z. B. Kundenanfragen, Rechnungen, Projekt-Updates",
"autoTrashSpam": "Spam automatisch in den Papierkorb",
"autoTrashHint": "Verschiebt als Spam eingestufte E-Mails in den Gmail-Papierkorb (30 Tage wiederherstellbar).",
"connecting": "Verbinde…",
"filterHint": "Leer lassen, um den ganzen Posteingang zu überwachen, oder nach Label und Absender filtern.",
"filterLabels": "Labels",
"filterSenders": "Absender",
"filterSendersPlaceholder": "alice@example.com oder @client.co",
"watchAllInbox": "Kein Filter = überwacht den gesamten Posteingang.",
"skipFilter": "Überspringen",
"finish": "Fertig",
"connectedAs": "Verbunden als",
"reconnect": "Neu verbinden",
"disconnect": "Trennen",
"gmailAccessRequired": "Gmail-Zugriff erforderlich, um E-Mail-Vorschläge zu erhalten.",
"comingSoon": "Demnächst"

Merge these into each file's existing scouts object (don't create a second scouts key). Keep valid JSON (commas).

  • Step 2: Validate JSON
cd adiuvAI
for f in en it es fr de; do node -e "JSON.parse(require('fs').readFileSync('src/renderer/locales/$f/translation.json','utf8')); console.log('$f ok')"; done

Expected: en okde ok.

  • Step 3: Confirm common.save exists (used by the config panel Save button)
cd adiuvAI
node -e "const j=require('./src/renderer/locales/en/translation.json'); console.log(j.common && j.common.save ? 'has common.save' : 'MISSING')"

If MISSING, add "save": "Save" (and translations: it "Salva", es "Guardar", fr "Enregistrer", de "Speichern") to the common object in each file.

  • Step 4: Commit
cd adiuvAI
git add src/renderer/locales/
git commit -m "i18n: add cloud scout creation + config keys (5 languages)"

Task 14: Full typecheck + manual smoke

Files: none (verification)

  • Step 1: Full Electron typecheck
cd adiuvAI
npx tsc --noEmit 2>&1 | grep -iE "Scout|settings/" || echo "no scout-surface errors"

Expected: no scout-surface errors. Pre-existing unrelated errors (drizzle-executor, ChatChartBlock, etc.) are acceptable.

  • Step 2: Lint
cd adiuvAI
npm run lint 2>&1 | tail -5

Expected: no new errors in touched files.

  • Step 3: Manual smoke (document result, don't block on it)

Start the app, open Settings → Scouts → Create scout → Gmail. Verify: only name + focus + auto-trash on first cloud step; Connect Gmail opens consent; after consent, label/sender filter step appears; Finish creates a scout row showing "Connected as ". Open the scout's config panel: focus/filter/auto-trash editable, Disconnect works. Verify Teams/Outlook cards are disabled. Verify local-directory creation is unchanged.


PHASE E — Submodule bumps

Task 15: Bump submodule pointers

  • Step 1: Confirm both submodules committed
cd c:/Users/PC-Roby/Documents/_adiuvai_workspace
git -C api status --short
git -C adiuvAI status --short

Expected: clean (all work committed in prior tasks).

  • Step 2: Bump pointers
cd c:/Users/PC-Roby/Documents/_adiuvai_workspace
git add api adiuvAI
git commit -m "chore: bump submodules — cloud scout creation flow (Gmail)"

Plan Self-Review

Spec coverage:

  • Stepper branch (local untouched, cloud slim) — Tasks 9, 10.
  • OAuth during creation, scout created at connect step — Task 10.
  • Label + sender filter — Tasks 5, 6, 10.
  • Focus → prompt_template → scout_purpose — Tasks 2, 10 (created with promptTemplate: focus).
  • Auto-trash toggle — Tasks 1, 2, 10.
  • Config panel parity — Task 11.
  • BE label-list + disconnect routes — Task 6.
  • Serializer oauthConnected + filterConfig + gmail_address — Tasks 2, 4.
  • gmail_address column (Alembic 009) — Task 3.
  • Catalog gating Teams/Outlook — Task 12.
  • i18n 5 langs — Task 13.
  • Added beyond spec (necessary): foundational cloud CRUD routes (Tasks 1, 2) — the spec assumed these existed; they don't.

Placeholder scan: no TBDs. Where a real symbol name might differ (e.g. get_session patch target, item.provider vs item.type), the task gives a grep/cat to confirm and adapt — task-time verification, not a placeholder.

Type consistency: CloudScoutResponse (snake_case, BE) ↔ CloudScoutConfigSchema (camelCase, TS) fields align: auto_trash_spamautoTrashSpam, filter_configfilterConfig, gmail_addressgmailAddress, oauth_connectedoauthConnected. filterConfig shape { labels?, senders? } consistent across Tasks 2, 7, 8, 10, 11. scout.cloud.create returns { data, error } — consumed correctly in Task 10 (res.data.id).

Scope: one coherent track — BE foundational CRUD + Gmail connector additions + Electron flow split. Shippable as one PR. Phase 4 (categorization/brief) remains out of scope.


Execution Handoff

Plan complete and saved to docs/superpowers/specs/2026-05-16-cloud-scout-creation-flow-plan.md. Two execution options:

1. Subagent-Driven (recommended) — fresh subagent per task, review between, fast iteration.

2. Inline Execution — execute in this session with checkpoints.

Note: planning uncovered that the backend cloud-CRUD routes never existed (cloud scouts 404 today). Tasks 12 build them — larger than the spec implied but required for any cloud scout to function.

Which approach?