24 KiB
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.mddocs/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/channelsstatic 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:
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:
ChannelConnectionResponse,
ChannelConnectionView,
ChannelConnectorDescriptor,
ConnectorSessionResponse,
- Step 3: Add connector API functions
Append to app-instance/frontend/lib/api.ts near the channel API functions:
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:
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
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:
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:
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:
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:
cd app-instance/frontend
npm run test -- lib/channel-connectors.test.ts
Expected: helper tests pass.
- Step 5: Commit Task 2
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:
'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:
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:
const [channelConnectors, setChannelConnectors] = useState<ChannelConnectorDescriptor[]>([]);
const [channelConnections, setChannelConnections] = useState<ChannelConnectionView[]>([]);
Add loader:
const loadChannelConnectors = async () => {
const [connectors, connections] = await Promise.all([
listChannelConnectors(),
listChannelConnections(),
]);
setChannelConnectors(connectors);
setChannelConnections(connections);
};
Call it after status load:
useEffect(() => {
loadStatus();
loadChannelConnectors().catch(() => undefined);
}, []);
In handleSaveChannel() after await loadStatus();, add:
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:
<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:
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
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:
cd app-instance/frontend
npm run build
Expected: Next build succeeds.
- Step 2: Start frontend dev server if visual smoke is needed
Run:
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:
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:
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:
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:
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:
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:
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-connectorsreturnstelegram,weixin, andfeishu. -
Step 7: Stop fake sidecar if no longer needed
Run:
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:
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:
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:
cd external-connector
uv run pytest -q
Expected: all sidecar tests pass.
- Step 4: Scan for provider-runtime naming in new files
Run:
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:
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.