# External Connector Frontend And Deploy 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:** Add a connector-driven onboarding UI for Weixin and Feishu/Lark, wire frontend API helpers to backend connector-session APIs, and verify the docker-compose sidecar deployment path.

**Architecture:** The Status page keeps the existing advanced channel config editor, but adds a connector onboarding section backed by `/api/channel-connectors`, `/api/channel-connections`, and `/api/channel-connector-sessions`. Weixin shows QR status; Feishu/Lark shows provider instructions/status. Successful sessions become active without restart through backend dynamic runtime activation.

**Tech Stack:** Next.js 13, React, TypeScript, existing shadcn/Radix UI components, lucide-react, Vitest, Docker Compose.

---

## Dependencies

Execute after:

- `docs/superpowers/plans/2026-06-03-external-connector-backend-runtime.md`
- `docs/superpowers/plans/2026-06-03-external-connector-sidecar.md`

## Scope

Included:

- Frontend TypeScript API helpers and types for connectors, connections, and connector sessions.
- Status page connector onboarding UI.
- QR/instruction modal and polling.
- Logout/revoke action using existing connection revoke API.
- Frontend tests for API mapping and UI state helpers.
- Docker compose smoke verification instructions for local sidecar.

Excluded:

- Replacing the advanced `/api/channels` static config editor.
- Live vendor account verification logic inside frontend.
- New top-level navigation route.

## File Structure

- Modify `app-instance/frontend/types/index.ts`
  - Add connector and connector-session types.
- Modify `app-instance/frontend/lib/api.ts`
  - Add connector API functions.
- Create `app-instance/frontend/lib/channel-connectors.ts`
  - Small UI state helpers for connector labels/status.
- Create `app-instance/frontend/components/channel-connector-wizard.tsx`
  - Connector cards, session modal, QR/instruction rendering, poll controls.
- Modify `app-instance/frontend/app/(app)/status/page.tsx`
  - Fetch connector data and render wizard above advanced Channels list.
- Create `app-instance/frontend/lib/channel-connectors.test.ts`
  - Helper tests.
- Create `app-instance/frontend/components/channel-connector-wizard.test.tsx`
  - Component tests if the existing Vitest setup supports React Testing Library; otherwise keep helper tests and verify with typecheck/build.
- Review `docker-compose.external-connectors.yml`
  - Confirm sidecar env names match backend and frontend assumptions.

---

### Task 1: Frontend Types And API Client

**Files:**
- Modify: `app-instance/frontend/types/index.ts`
- Modify: `app-instance/frontend/lib/api.ts`
- Test: `app-instance/frontend/lib/channel-connectors.test.ts`

- [ ] **Step 1: Add frontend connector types**

Append to `app-instance/frontend/types/index.ts`:

```ts
export interface ChannelConnectorDescriptor {
  kind: string;
  displayName?: string;
  display_name?: string;
  authType?: string;
  auth_type?: string;
  providerId?: string;
  provider_id?: string;
  capabilities?: string[];
  available?: boolean;
  unavailableReason?: string | null;
}

export interface ChannelConnectionView {
  connection_id: string;
  owner_user_id?: string | null;
  channel_id: string;
  kind: string;
  mode: string;
  display_name: string;
  account_id: string;
  status: string;
  auth_type: string;
  runtime_config: Record<string, unknown>;
  capabilities: string[];
  created_at: string;
  updated_at: string;
  last_seen_at?: string | null;
  last_error?: string | null;
}

export interface ChannelConnectionResponse {
  connection: ChannelConnectionView;
  credentials?: Record<string, string>;
}

export interface ConnectorSessionView {
  sessionId: string;
  kind: string;
  status: string;
  qrCode?: string | null;
  qrImage?: string | null;
  instructions?: string[];
  accountId?: string | null;
  displayName?: string | null;
  error?: string | null;
  metadata?: Record<string, unknown>;
}

export interface ConnectorSessionResponse {
  session: ConnectorSessionView;
  connection?: ChannelConnectionView | null;
}
```

- [ ] **Step 2: Add API imports**

Modify the import list in `app-instance/frontend/lib/api.ts` to include:

```ts
  ChannelConnectionResponse,
  ChannelConnectionView,
  ChannelConnectorDescriptor,
  ConnectorSessionResponse,
```

- [ ] **Step 3: Add connector API functions**

Append to `app-instance/frontend/lib/api.ts` near the channel API functions:

```ts
export async function listChannelConnectors(): Promise<ChannelConnectorDescriptor[]> {
  return fetchJSON('/api/channel-connectors');
}

export async function listChannelConnections(): Promise<ChannelConnectionView[]> {
  return fetchJSON('/api/channel-connections');
}

export async function startConnectorSession(params: {
  kind: string;
  displayName?: string;
  ownerUserId?: string;
  options?: Record<string, unknown>;
}): Promise<ConnectorSessionResponse> {
  return fetchJSON('/api/channel-connector-sessions', {
    method: 'POST',
    timeoutMs: 45000,
    body: JSON.stringify({
      kind: params.kind,
      displayName: params.displayName,
      ownerUserId: params.ownerUserId,
      options: params.options || {},
    }),
  });
}

export async function getConnectorSession(sessionId: string): Promise<ConnectorSessionResponse> {
  return fetchJSON(`/api/channel-connector-sessions/${encodeURIComponent(sessionId)}`, {
    timeoutMs: 45000,
  });
}

export async function revokeChannelConnection(connectionId: string): Promise<ChannelConnectionResponse> {
  return fetchJSON(`/api/channel-connections/${encodeURIComponent(connectionId)}/revoke`, {
    method: 'POST',
  });
}
```

- [ ] **Step 4: Run frontend typecheck**

Run:

```bash
cd app-instance/frontend
npm run typecheck
```

Expected: typecheck passes. If it fails because these types are appended inside another interface, move them below the closing brace for `SystemStatus`.

- [ ] **Step 5: Commit Task 1**

```bash
git add app-instance/frontend/types/index.ts app-instance/frontend/lib/api.ts
git commit -m "feat: add connector frontend api client"
```

---

### Task 2: Connector UI Helpers

**Files:**
- Create: `app-instance/frontend/lib/channel-connectors.ts`
- Create: `app-instance/frontend/lib/channel-connectors.test.ts`

- [ ] **Step 1: Write helper tests**

Create `app-instance/frontend/lib/channel-connectors.test.ts`:

```ts
import { describe, expect, it } from 'vitest';
import {
  connectorDisplayName,
  connectorStatusLabel,
  isTerminalConnectorSessionStatus,
} from './channel-connectors';

describe('channel connector helpers', () => {
  it('returns friendly connector names', () => {
    expect(connectorDisplayName({ kind: 'weixin' })).toBe('Weixin');
    expect(connectorDisplayName({ kind: 'feishu' })).toBe('Feishu/Lark');
    expect(connectorDisplayName({ kind: 'telegram', displayName: 'Telegram' })).toBe('Telegram');
  });

  it('maps connector session statuses', () => {
    expect(connectorStatusLabel('qr_ready')).toBe('QR ready');
    expect(connectorStatusLabel('waiting_for_user')).toBe('Waiting for user');
    expect(connectorStatusLabel('connected')).toBe('Connected');
  });

  it('detects terminal statuses', () => {
    expect(isTerminalConnectorSessionStatus('connected')).toBe(true);
    expect(isTerminalConnectorSessionStatus('expired')).toBe(true);
    expect(isTerminalConnectorSessionStatus('qr_ready')).toBe(false);
  });
});
```

- [ ] **Step 2: Run tests to verify failure**

Run:

```bash
cd app-instance/frontend
npm run test -- lib/channel-connectors.test.ts
```

Expected: fail with `Cannot find module './channel-connectors'`.

- [ ] **Step 3: Implement helpers**

Create `app-instance/frontend/lib/channel-connectors.ts`:

```ts
import type { ChannelConnectorDescriptor } from '@/types';

export function connectorDisplayName(connector: Pick<ChannelConnectorDescriptor, 'kind' | 'displayName' | 'display_name'>): string {
  if (connector.displayName) return connector.displayName;
  if (connector.display_name) return connector.display_name;
  if (connector.kind === 'weixin') return 'Weixin';
  if (connector.kind === 'feishu') return 'Feishu/Lark';
  if (connector.kind === 'telegram') return 'Telegram';
  return connector.kind;
}

export function connectorStatusLabel(status: string): string {
  const labels: Record<string, string> = {
    pending: 'Pending',
    qr_ready: 'QR ready',
    scanned: 'Scanned',
    confirmed: 'Confirmed',
    installing: 'Installing',
    waiting_for_user: 'Waiting for user',
    connected: 'Connected',
    expired: 'Expired',
    error: 'Error',
    cancelled: 'Cancelled',
  };
  return labels[status] || status;
}

export function isTerminalConnectorSessionStatus(status: string): boolean {
  return ['connected', 'expired', 'error', 'cancelled'].includes(status);
}
```

- [ ] **Step 4: Run helper tests**

Run:

```bash
cd app-instance/frontend
npm run test -- lib/channel-connectors.test.ts
```

Expected: helper tests pass.

- [ ] **Step 5: Commit Task 2**

```bash
git add app-instance/frontend/lib/channel-connectors.ts app-instance/frontend/lib/channel-connectors.test.ts
git commit -m "feat: add channel connector ui helpers"
```

---

### Task 3: Connector Wizard Component

**Files:**
- Create: `app-instance/frontend/components/channel-connector-wizard.tsx`
- Modify: `app-instance/frontend/app/(app)/status/page.tsx`

- [ ] **Step 1: Create wizard component**

Create `app-instance/frontend/components/channel-connector-wizard.tsx`:

```tsx
'use client';

import React, { useEffect, useMemo, useState } from 'react';
import { CheckCircle2, Loader2, QrCode, RefreshCw, Unplug } from 'lucide-react';
import type {
  ChannelConnectionView,
  ChannelConnectorDescriptor,
  ConnectorSessionResponse,
  ConnectorSessionView,
} from '@/types';
import {
  getConnectorSession,
  revokeChannelConnection,
  startConnectorSession,
} from '@/lib/api';
import {
  connectorDisplayName,
  connectorStatusLabel,
  isTerminalConnectorSessionStatus,
} from '@/lib/channel-connectors';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
  Dialog,
  DialogContent,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';

type Props = {
  connectors: ChannelConnectorDescriptor[];
  connections: ChannelConnectionView[];
  onChanged: () => Promise<void> | void;
};

export function ChannelConnectorWizard({ connectors, connections, onChanged }: Props) {
  const [activeKind, setActiveKind] = useState<string | null>(null);
  const [session, setSession] = useState<ConnectorSessionView | null>(null);
  const [connection, setConnection] = useState<ChannelConnectionView | null>(null);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [feishuDomain, setFeishuDomain] = useState('feishu');

  const visibleConnectors = useMemo(
    () => connectors.filter((item) => ['telegram', 'weixin', 'feishu'].includes(item.kind)),
    [connectors],
  );

  useEffect(() => {
    if (!session || isTerminalConnectorSessionStatus(session.status)) return;
    const timer = window.setInterval(async () => {
      try {
        const next = await getConnectorSession(session.sessionId);
        setSession(next.session);
        if (next.connection) setConnection(next.connection);
        if (next.session.status === 'connected') await onChanged();
      } catch (err: any) {
        setError(err.message || 'Failed to refresh connector session');
      }
    }, 2000);
    return () => window.clearInterval(timer);
  }, [session?.sessionId, session?.status, onChanged]);

  const start = async (kind: string) => {
    setActiveKind(kind);
    setSession(null);
    setConnection(null);
    setError(null);
    setBusy(true);
    try {
      const options = kind === 'feishu' ? { domain: feishuDomain } : {};
      const response: ConnectorSessionResponse = await startConnectorSession({
        kind,
        displayName: connectorDisplayName({ kind }),
        options,
      });
      setSession(response.session);
      setConnection(response.connection || null);
    } catch (err: any) {
      setError(err.message || 'Failed to start connector session');
    } finally {
      setBusy(false);
    }
  };

  const revoke = async (item: ChannelConnectionView) => {
    setBusy(true);
    setError(null);
    try {
      await revokeChannelConnection(item.connection_id);
      await onChanged();
    } catch (err: any) {
      setError(err.message || 'Failed to logout connector');
    } finally {
      setBusy(false);
    }
  };

  return (
    <section className="space-y-3">
      <div className="grid gap-3 md:grid-cols-3">
        {visibleConnectors.map((connector) => {
          const existing = connections.find((item) => item.kind === connector.kind && item.status !== 'revoked');
          return (
            <Card key={connector.kind} className="rounded-md">
              <CardHeader className="pb-2">
                <CardTitle className="flex items-center justify-between text-base">
                  <span>{connectorDisplayName(connector)}</span>
                  {existing ? <Badge variant="secondary">{existing.status}</Badge> : null}
                </CardTitle>
              </CardHeader>
              <CardContent className="space-y-3">
                {connector.kind === 'feishu' ? (
                  <div className="space-y-1">
                    <Label htmlFor="feishu-domain">Domain</Label>
                    <Input id="feishu-domain" value={feishuDomain} onChange={(event) => setFeishuDomain(event.target.value)} />
                  </div>
                ) : null}
                {existing ? (
                  <div className="flex items-center justify-between gap-2 text-sm">
                    <span className="truncate">{existing.display_name || existing.account_id || existing.channel_id}</span>
                    <Button size="sm" variant="outline" onClick={() => revoke(existing)} disabled={busy}>
                      <Unplug className="mr-2 h-4 w-4" />
                      Logout
                    </Button>
                  </div>
                ) : (
                  <Button size="sm" onClick={() => start(connector.kind)} disabled={busy || connector.kind === 'telegram'}>
                    {busy && activeKind === connector.kind ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <QrCode className="mr-2 h-4 w-4" />}
                    {connector.kind === 'telegram' ? 'Use token setup' : 'Connect'}
                  </Button>
                )}
              </CardContent>
            </Card>
          );
        })}
      </div>

      {error ? <p className="text-sm text-destructive">{error}</p> : null}

      <Dialog open={Boolean(activeKind && session)} onOpenChange={(open) => !open && setActiveKind(null)}>
        <DialogContent className="sm:max-w-[520px]">
          <DialogHeader>
            <DialogTitle>{activeKind ? connectorDisplayName({ kind: activeKind }) : 'Connector'}</DialogTitle>
          </DialogHeader>
          {session ? (
            <div className="space-y-4">
              <div className="flex items-center justify-between">
                <Badge variant={session.status === 'connected' ? 'default' : 'secondary'}>
                  {connectorStatusLabel(session.status)}
                </Badge>
                {session.status === 'connected' ? <CheckCircle2 className="h-5 w-5 text-emerald-600" /> : <RefreshCw className="h-5 w-5 text-muted-foreground" />}
              </div>
              {session.qrImage ? (
                <img alt="Connector QR code" src={session.qrImage} className="mx-auto aspect-square w-64 rounded-md border object-contain" />
              ) : null}
              {session.instructions && session.instructions.length > 0 ? (
                <div className="space-y-2 rounded-md border p-3 text-sm">
                  {session.instructions.map((item) => <p key={item}>{item}</p>)}
                </div>
              ) : null}
              {connection ? <p className="text-sm text-muted-foreground">{connection.display_name || connection.account_id}</p> : null}
              {session.error ? <p className="text-sm text-destructive">{session.error}</p> : null}
            </div>
          ) : null}
          <DialogFooter>
            <Button variant="outline" onClick={() => setActiveKind(null)}>Close</Button>
          </DialogFooter>
        </DialogContent>
      </Dialog>
    </section>
  );
}
```

- [ ] **Step 2: Wire Status page imports**

Modify imports in `app-instance/frontend/app/(app)/status/page.tsx`:

```tsx
import { ChannelConnectorWizard } from '@/components/channel-connector-wizard';
import { getChannelConfig, getStatus, listChannelConnections, listChannelConnectors, listChannelEvents, restartRuntime, updateAgentConfig, updateChannelConfig, updateProviderConfig } from '@/lib/api';
import type { ChannelConfigDetail, ChannelConnectionView, ChannelConnectorDescriptor, ChannelEventRecord, ChannelStatus, ProviderStatus, SystemStatus } from '@/types';
```

- [ ] **Step 3: Add connector state to Status page**

Inside `StatusPage()` state declarations:

```tsx
  const [channelConnectors, setChannelConnectors] = useState<ChannelConnectorDescriptor[]>([]);
  const [channelConnections, setChannelConnections] = useState<ChannelConnectionView[]>([]);
```

Add loader:

```tsx
  const loadChannelConnectors = async () => {
    const [connectors, connections] = await Promise.all([
      listChannelConnectors(),
      listChannelConnections(),
    ]);
    setChannelConnectors(connectors);
    setChannelConnections(connections);
  };
```

Call it after status load:

```tsx
  useEffect(() => {
    loadStatus();
    loadChannelConnectors().catch(() => undefined);
  }, []);
```

In `handleSaveChannel()` after `await loadStatus();`, add:

```tsx
      await loadChannelConnectors();
```

- [ ] **Step 4: Render wizard above advanced Channels list**

In `app-instance/frontend/app/(app)/status/page.tsx`, render before the existing `{/* Channels */}` section:

```tsx
      <section className="space-y-3">
        <div>
          <h2 className="text-lg font-semibold">{pickAppText(locale, '连接器', 'Connectors')}</h2>
          <p className="text-sm text-muted-foreground">
            {pickAppText(locale, '连接微信或飞书后会立即进入运行时。', 'Connected Weixin or Feishu channels activate immediately.')}
          </p>
        </div>
        <ChannelConnectorWizard
          connectors={channelConnectors}
          connections={channelConnections}
          onChanged={async () => {
            await loadChannelConnectors();
            await loadStatus();
          }}
        />
      </section>
```

- [ ] **Step 5: Run frontend checks**

Run:

```bash
cd app-instance/frontend
npm run typecheck
npm run test -- lib/channel-connectors.test.ts
```

Expected: typecheck and helper tests pass.

- [ ] **Step 6: Commit Task 3**

```bash
git add app-instance/frontend/components/channel-connector-wizard.tsx app-instance/frontend/app/'(app)'/status/page.tsx
git commit -m "feat: add channel connector wizard"
```

---

### Task 4: Frontend Build And Browser Smoke

**Files:**
- Review: `app-instance/frontend/app/(app)/status/page.tsx`
- Review: `app-instance/frontend/components/channel-connector-wizard.tsx`

- [ ] **Step 1: Run frontend build**

Run:

```bash
cd app-instance/frontend
npm run build
```

Expected: Next build succeeds.

- [ ] **Step 2: Start frontend dev server if visual smoke is needed**

Run:

```bash
cd app-instance/frontend
npm run dev
```

Expected: dev server listens on `http://127.0.0.1:3080`.

- [ ] **Step 3: Browser smoke check**

Open the Status page in the running app instance and verify:

- The Connectors section appears above Channels.
- Telegram shows token setup disabled in the connector wizard.
- Weixin has a Connect button.
- Feishu/Lark has a Domain input and Connect button.
- Starting a fake Weixin session opens a modal with a QR image.

- [ ] **Step 4: Stop frontend dev server**

If Step 2 started a dev server, stop it with `Ctrl-C`.

- [ ] **Step 5: Commit fixes if needed**

If build or smoke required fixes:

```bash
git add app-instance/frontend
git commit -m "fix: stabilize channel connector wizard"
```

If no files changed, do not create an empty commit.

---

### Task 5: Compose Integration Verification

**Files:**
- Review: `docker-compose.external-connectors.yml`
- Review: `.env.example`

- [ ] **Step 1: Build backend and sidecar images**

Run:

```bash
docker build -t beaver/app-instance:latest app-instance
docker compose -f docker-compose.external-connectors.yml build external-connector
```

Expected: both builds succeed.

- [ ] **Step 2: Start sidecar with fake provider**

Run:

```bash
CONNECTOR_PROVIDER=fake \
EXTERNAL_CONNECTOR_TOKEN=dev-token \
BEAVER_BRIDGE_TOKEN=dev-token \
docker compose -f docker-compose.external-connectors.yml up -d external-connector
```

Expected: `external-connector` starts and stays running.

- [ ] **Step 3: Verify sidecar connector API**

Run:

```bash
curl -sS -H 'Authorization: Bearer dev-token' http://127.0.0.1:8787/connectors
```

Expected: JSON contains `weixin` and `feishu`.

- [ ] **Step 4: Attach sidecar to Beaver instance network**

For a local `create-instance.sh` deployment using `beaver-instance-edge`, run:

```bash
docker network connect beaver-instance-edge external-connector 2>/dev/null || true
```

Expected: command succeeds or reports that the endpoint already exists.

- [ ] **Step 5: Restart target app instance with connector env**

For `terminaltest`, ensure the app container has:

```dotenv
EXTERNAL_CONNECTOR_BASE_URL=http://external-connector:8787
EXTERNAL_CONNECTOR_TOKEN=dev-token
BEAVER_BRIDGE_TOKEN=dev-token
EXTERNAL_CONNECTOR_CALLBACK_BASE_URL=http://app-instance-terminaltest:8080
```

Then recreate the instance with the deployment script used by this repo. Do not mount `/var/run/docker.sock` into Beaver.
For multi-instance deployments, this callback URL must point at the specific app-instance container that owns the connection; the shared sidecar stores it per connector session and uses it for inbound events.

- [ ] **Step 6: Manual fake-provider onboarding**

In `terminaltest`:

- Open Status.
- Click Weixin Connect.
- Confirm QR modal appears.
- Poll until fake status remains visible.
- Confirm backend `/api/channel-connectors` returns `telegram`, `weixin`, and `feishu`.

- [ ] **Step 7: Stop fake sidecar if no longer needed**

Run:

```bash
docker compose -f docker-compose.external-connectors.yml down
```

Expected: sidecar stops; named volume remains.

---

### Task 6: Final Frontend And Deploy Verification

**Files:**
- Review: `docs/superpowers/specs/2026-06-02-external-sidecar-connectors-design.md`

- [ ] **Step 1: Run frontend verification**

Run:

```bash
cd app-instance/frontend
npm run typecheck
npm run build
npm run test -- lib/channel-connectors.test.ts
```

Expected: all commands pass.

- [ ] **Step 2: Run backend connector smoke tests**

Run:

```bash
cd app-instance/backend
uv run pytest \
  tests/unit/test_external_sidecar_connectors.py \
  tests/unit/test_external_connector_bridge_api.py \
  tests/unit/test_channel_runtime_dynamic_channels.py \
  -q
```

Expected: all listed tests pass.

- [ ] **Step 3: Run sidecar verification**

Run:

```bash
cd external-connector
uv run pytest -q
```

Expected: all sidecar tests pass.

- [ ] **Step 4: Scan for provider-runtime naming in new files**

Run:

```bash
rg -n "[Oo]pen[Cc]law" docs/superpowers app-instance/frontend external-connector docker-compose.external-connectors.yml || true
```

Expected: no matches.

- [ ] **Step 5: Commit verification fixes if needed**

If any verification step required fixes:

```bash
git add app-instance/frontend external-connector docker-compose.external-connectors.yml docs/superpowers
git commit -m "fix: stabilize external connector onboarding"
```

If no files changed, do not create an empty commit.
