# 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; capabilities: string[]; created_at: string; updated_at: string; last_seen_at?: string | null; last_error?: string | null; } export interface ChannelConnectionResponse { connection: ChannelConnectionView; credentials?: Record; } 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; } 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 { return fetchJSON('/api/channel-connectors'); } export async function listChannelConnections(): Promise { return fetchJSON('/api/channel-connections'); } export async function startConnectorSession(params: { kind: string; displayName?: string; ownerUserId?: string; options?: Record; }): Promise { 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 { return fetchJSON(`/api/channel-connector-sessions/${encodeURIComponent(sessionId)}`, { timeoutMs: 45000, }); } export async function revokeChannelConnection(connectionId: string): Promise { 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): 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 = { 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; }; export function ChannelConnectorWizard({ connectors, connections, onChanged }: Props) { const [activeKind, setActiveKind] = useState(null); const [session, setSession] = useState(null); const [connection, setConnection] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(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 (
{visibleConnectors.map((connector) => { const existing = connections.find((item) => item.kind === connector.kind && item.status !== 'revoked'); return ( {connectorDisplayName(connector)} {existing ? {existing.status} : null} {connector.kind === 'feishu' ? (
setFeishuDomain(event.target.value)} />
) : null} {existing ? (
{existing.display_name || existing.account_id || existing.channel_id}
) : ( )}
); })}
{error ?

{error}

: null} !open && setActiveKind(null)}> {activeKind ? connectorDisplayName({ kind: activeKind }) : 'Connector'} {session ? (
{connectorStatusLabel(session.status)} {session.status === 'connected' ? : }
{session.qrImage ? ( Connector QR code ) : null} {session.instructions && session.instructions.length > 0 ? (
{session.instructions.map((item) =>

{item}

)}
) : null} {connection ?

{connection.display_name || connection.account_id}

: null} {session.error ?

{session.error}

: null}
) : null}
); } ``` - [ ] **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([]); const [channelConnections, setChannelConnections] = useState([]); ``` 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

{pickAppText(locale, '连接器', 'Connectors')}

{pickAppText(locale, '连接微信或飞书后会立即进入运行时。', 'Connected Weixin or Feishu channels activate immediately.')}

{ await loadChannelConnectors(); await loadStatus(); }} />
``` - [ ] **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 ``` Then recreate the instance with the deployment script used by this repo. Do not mount `/var/run/docker.sock` into Beaver. - [ ] **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.