# External Connector Backend Runtime 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 Beaver backend support for sidecar-backed Weixin and Feishu connector sessions, bridge-event dedupe, outbound sidecar delivery, and dynamic runtime channel activation.

**Architecture:** Beaver depends only on a generic connector HTTP contract. Sidecar-backed connections materialize as `ChannelConfig(kind="external_connector", mode="http")`, are registered dynamically through `ChannelRuntime.add_channel()`, and accept inbound events through an authenticated bridge endpoint with `MessageDedupeStore` idempotency.

**Tech Stack:** Python dataclasses, FastAPI, Pydantic v2, local JSON stores, pytest, existing Beaver channel runtime.

---

## Scope

Included:

- JSON-backed bridge event dedupe with 60-second stale processing TTL.
- `ExternalConnectorChannel` adapter for outbound `/send` calls with stable `requestId`.
- Runtime and manager dynamic add/remove channel support.
- Beaver sidecar HTTP client.
- Weixin and Feishu connector registry entries backed by fake sidecar clients in tests.
- Backend APIs for connector sessions and bridge inbound events.

Excluded:

- Sidecar process implementation.
- Frontend connector wizard.
- Docker compose integration.
- Live vendor CLI verification.

## File Structure

- Create `app-instance/backend/beaver/interfaces/channels/connections/dedupe.py`
  - `ConnectorMessageDedupeRecord` and `MessageDedupeStore`.
- Create `app-instance/backend/beaver/interfaces/channels/connections/sidecar_client.py`
  - Async HTTP client for generic sidecar contract.
- Create `app-instance/backend/beaver/interfaces/channels/connections/external.py`
  - `ExternalConnectorBase`, `WeixinConnector`, and `FeishuConnector`.
- Create `app-instance/backend/beaver/interfaces/channels/external_connector.py`
  - Runtime adapter that sends outbound messages to the sidecar.
- Modify `app-instance/backend/beaver/interfaces/channels/manager.py`
  - Allow dynamic register/unregister while manager is started.
- Modify `app-instance/backend/beaver/interfaces/channels/runtime.py`
  - Add lifecycle lock, `add_channel()`, `remove_channel()`, and adapter factory support for `external_connector/http`.
- Modify `app-instance/backend/beaver/interfaces/channels/connections/connectors.py`
  - Materialize sidecar-backed connections and optionally activate runtime.
- Modify `app-instance/backend/beaver/interfaces/channels/connections/__init__.py`
  - Export new dedupe, client, and connector symbols.
- Modify `app-instance/backend/beaver/interfaces/web/schemas/chat.py`
  - Add connector session and bridge schemas.
- Modify `app-instance/backend/beaver/interfaces/web/schemas/__init__.py`
  - Export new schemas.
- Modify `app-instance/backend/beaver/interfaces/web/app.py`
  - Register Weixin/Feishu connectors, sidecar settings, connector session routes, and bridge endpoint.
- Test `app-instance/backend/tests/unit/test_connector_message_dedupe_store.py`
- Test `app-instance/backend/tests/unit/test_external_connector_channel.py`
- Test `app-instance/backend/tests/unit/test_channel_runtime_dynamic_channels.py`
- Test `app-instance/backend/tests/unit/test_external_connector_bridge_api.py`
- Test `app-instance/backend/tests/unit/test_external_sidecar_connectors.py`

---

### Task 1: Message Dedupe Store

**Files:**
- Create: `app-instance/backend/beaver/interfaces/channels/connections/dedupe.py`
- Modify: `app-instance/backend/beaver/interfaces/channels/connections/__init__.py`
- Test: `app-instance/backend/tests/unit/test_connector_message_dedupe_store.py`

- [ ] **Step 1: Write failing dedupe tests**

Create `app-instance/backend/tests/unit/test_connector_message_dedupe_store.py`:

```python
from __future__ import annotations

from beaver.interfaces.channels.connections import MessageDedupeStore


def test_message_dedupe_store_completes_and_dedupes_completed(tmp_path) -> None:
    store = MessageDedupeStore(tmp_path / "message_dedupe.json")

    first = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=1)
    store.complete(first.dedupe_key, message_id="msg_1")
    duplicate = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=2)

    assert first.should_process is True
    assert duplicate.should_process is False
    assert duplicate.status == "completed"
    assert duplicate.http_status == 200


def test_message_dedupe_store_returns_conflict_for_active_processing(tmp_path) -> None:
    store = MessageDedupeStore(tmp_path / "message_dedupe.json", processing_ttl_seconds=60)

    store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=1)
    duplicate = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=2)

    assert duplicate.should_process is False
    assert duplicate.status == "processing"
    assert duplicate.http_status == 409
    assert duplicate.retry_after_seconds == 5


def test_message_dedupe_store_reprocesses_stale_processing(tmp_path) -> None:
    store = MessageDedupeStore(tmp_path / "message_dedupe.json", processing_ttl_seconds=0)

    store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=1)
    stale = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=2)

    assert stale.should_process is True
    assert stale.status == "processing"
    assert stale.record.delivery_attempts == 2


def test_message_dedupe_store_reprocesses_failed_records(tmp_path) -> None:
    store = MessageDedupeStore(tmp_path / "message_dedupe.json")

    first = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=1)
    store.fail(first.dedupe_key, error="runtime rejected")
    retry = store.begin(connection_id="conn_1", event_id="evt_1", delivery_attempt=2)

    assert retry.should_process is True
    assert retry.record.delivery_attempts == 2
    assert retry.record.last_error is None
```

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

Run:

```bash
cd app-instance/backend
uv run pytest tests/unit/test_connector_message_dedupe_store.py -q
```

Expected: fail with `ImportError: cannot import name 'MessageDedupeStore'`.

- [ ] **Step 3: Implement dedupe models and store**

Create `app-instance/backend/beaver/interfaces/channels/connections/dedupe.py` with:

```python
from __future__ import annotations

import json
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from pathlib import Path
from threading import Lock
from typing import Any


def _iso_now() -> str:
    return datetime.now(timezone.utc).isoformat()


def _parse_iso(value: str) -> datetime:
    return datetime.fromisoformat(value.replace("Z", "+00:00"))


@dataclass(slots=True)
class ConnectorMessageDedupeRecord:
    dedupe_key: str
    connection_id: str
    event_id: str
    status: str
    first_seen_at: str
    updated_at: str
    delivery_attempts: int
    message_id: str | None = None
    last_error: str | None = None

    def to_dict(self) -> dict[str, Any]:
        return asdict(self)

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "ConnectorMessageDedupeRecord":
        return cls(
            dedupe_key=str(data.get("dedupe_key") or ""),
            connection_id=str(data.get("connection_id") or ""),
            event_id=str(data.get("event_id") or ""),
            status=str(data.get("status") or "processing"),
            first_seen_at=str(data.get("first_seen_at") or _iso_now()),
            updated_at=str(data.get("updated_at") or _iso_now()),
            delivery_attempts=int(data.get("delivery_attempts") or 0),
            message_id=str(data["message_id"]) if data.get("message_id") is not None else None,
            last_error=str(data["last_error"]) if data.get("last_error") is not None else None,
        )


@dataclass(slots=True)
class DedupeBeginResult:
    should_process: bool
    dedupe_key: str
    status: str
    http_status: int
    retry_after_seconds: int | None
    record: ConnectorMessageDedupeRecord


class MessageDedupeStore:
    def __init__(self, path: Path, *, processing_ttl_seconds: int = 60) -> None:
        self.path = Path(path)
        self.processing_ttl_seconds = int(processing_ttl_seconds)
        self._lock = Lock()

    def begin(self, *, connection_id: str, event_id: str, delivery_attempt: int) -> DedupeBeginResult:
        dedupe_key = f"{connection_id}:{event_id}"
        now = _iso_now()
        with self._lock:
            data = self._load()
            raw = data["records"].get(dedupe_key)
            if isinstance(raw, dict):
                record = ConnectorMessageDedupeRecord.from_dict(raw)
                if record.status == "completed":
                    return DedupeBeginResult(False, dedupe_key, record.status, 200, None, record)
                if record.status == "processing" and not self._is_stale(record, now):
                    return DedupeBeginResult(False, dedupe_key, record.status, 409, 5, record)
                record.status = "processing"
                record.updated_at = now
                record.delivery_attempts = max(record.delivery_attempts + 1, int(delivery_attempt))
                record.last_error = None
            else:
                record = ConnectorMessageDedupeRecord(
                    dedupe_key=dedupe_key,
                    connection_id=connection_id,
                    event_id=event_id,
                    status="processing",
                    first_seen_at=now,
                    updated_at=now,
                    delivery_attempts=max(1, int(delivery_attempt)),
                )
            data["records"][dedupe_key] = record.to_dict()
            self._save(data)
            return DedupeBeginResult(True, dedupe_key, record.status, 200, None, record)

    def complete(self, dedupe_key: str, *, message_id: str | None) -> ConnectorMessageDedupeRecord:
        return self._mark(dedupe_key, status="completed", message_id=message_id, error=None)

    def fail(self, dedupe_key: str, *, error: str) -> ConnectorMessageDedupeRecord:
        return self._mark(dedupe_key, status="failed", message_id=None, error=error)

    def _mark(
        self,
        dedupe_key: str,
        *,
        status: str,
        message_id: str | None,
        error: str | None,
    ) -> ConnectorMessageDedupeRecord:
        with self._lock:
            data = self._load()
            raw = data["records"].get(dedupe_key)
            if not isinstance(raw, dict):
                raise KeyError(dedupe_key)
            record = ConnectorMessageDedupeRecord.from_dict(raw)
            record.status = status
            record.updated_at = _iso_now()
            record.message_id = message_id or record.message_id
            record.last_error = error
            data["records"][dedupe_key] = record.to_dict()
            self._save(data)
            return record

    def _is_stale(self, record: ConnectorMessageDedupeRecord, now: str) -> bool:
        age = (_parse_iso(now) - _parse_iso(record.updated_at)).total_seconds()
        return age >= self.processing_ttl_seconds

    def _load(self) -> dict[str, Any]:
        if not self.path.exists():
            return {"records": {}}
        try:
            data = json.loads(self.path.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError):
            return {"records": {}}
        if not isinstance(data, dict) or not isinstance(data.get("records"), dict):
            return {"records": {}}
        return data

    def _save(self, data: dict[str, Any]) -> None:
        self.path.parent.mkdir(parents=True, exist_ok=True)
        tmp_path = self.path.with_name(f"{self.path.name}.tmp")
        tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
        tmp_path.replace(self.path)
```

- [ ] **Step 4: Export dedupe symbols**

Modify `app-instance/backend/beaver/interfaces/channels/connections/__init__.py`:

```python
from .dedupe import ConnectorMessageDedupeRecord, DedupeBeginResult, MessageDedupeStore
```

Add these names to `__all__`.

- [ ] **Step 5: Run dedupe tests**

Run:

```bash
cd app-instance/backend
uv run pytest tests/unit/test_connector_message_dedupe_store.py -q
```

Expected: `4 passed`.

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

```bash
git add app-instance/backend/beaver/interfaces/channels/connections/dedupe.py app-instance/backend/beaver/interfaces/channels/connections/__init__.py app-instance/backend/tests/unit/test_connector_message_dedupe_store.py
git commit -m "feat: add connector bridge dedupe store"
```

---

### Task 2: Dynamic Runtime Channels

**Files:**
- Modify: `app-instance/backend/beaver/interfaces/channels/manager.py`
- Modify: `app-instance/backend/beaver/interfaces/channels/runtime.py`
- Test: `app-instance/backend/tests/unit/test_channel_runtime_dynamic_channels.py`

- [ ] **Step 1: Write failing dynamic runtime tests**

Create `app-instance/backend/tests/unit/test_channel_runtime_dynamic_channels.py`:

```python
from __future__ import annotations

import asyncio

from beaver.foundation.config.schema import ChannelConfig
from beaver.foundation.events import MessageBus, OutboundMessage
from beaver.interfaces.channels.runtime import ChannelRuntime


class FakeService:
    async def handle_inbound_message(self, inbound):
        return OutboundMessage(channel=inbound.channel, content="ok", session_id=inbound.session_id, finish_reason="stop")


def test_runtime_add_channel_starts_new_channel_after_runtime_start(tmp_path) -> None:
    async def run() -> None:
        runtime = ChannelRuntime(service=FakeService(), workspace=tmp_path, channels={}, bus=MessageBus())
        await runtime.start()
        try:
            await runtime.add_channel(
                "webhook-dev",
                ChannelConfig(enabled=True, kind="webhook", mode="webhook", account_id="acct"),
            )
            assert "webhook-dev" in runtime.adapters
            assert runtime.states["webhook-dev"]["state"] == "running"
        finally:
            await runtime.stop()

    asyncio.run(run())


def test_runtime_add_channel_noops_for_same_config(tmp_path) -> None:
    async def run() -> None:
        cfg = ChannelConfig(enabled=True, kind="webhook", mode="webhook", account_id="acct")
        runtime = ChannelRuntime(service=FakeService(), workspace=tmp_path, channels={}, bus=MessageBus())
        await runtime.start()
        try:
            await runtime.add_channel("webhook-dev", cfg)
            first = runtime.adapters["webhook-dev"]
            await runtime.add_channel("webhook-dev", cfg)
            assert runtime.adapters["webhook-dev"] is first
        finally:
            await runtime.stop()

    asyncio.run(run())


def test_runtime_replacement_failure_keeps_old_channel(tmp_path) -> None:
    async def run() -> None:
        good = ChannelConfig(enabled=True, kind="webhook", mode="webhook", account_id="acct")
        bad = ChannelConfig(enabled=True, kind="missing", mode="http", account_id="acct")
        runtime = ChannelRuntime(service=FakeService(), workspace=tmp_path, channels={}, bus=MessageBus())
        await runtime.start()
        try:
            await runtime.add_channel("webhook-dev", good)
            old = runtime.adapters["webhook-dev"]
            try:
                await runtime.add_channel("webhook-dev", bad)
            except ValueError:
                pass
            else:
                raise AssertionError("Expected ValueError")
            assert runtime.adapters["webhook-dev"] is old
            assert runtime.channel_configs["webhook-dev"] == good
            assert runtime.states["webhook-dev"]["state"] == "running"
        finally:
            await runtime.stop()

    asyncio.run(run())


def test_runtime_remove_channel_stops_and_unregisters(tmp_path) -> None:
    async def run() -> None:
        runtime = ChannelRuntime(service=FakeService(), workspace=tmp_path, channels={}, bus=MessageBus())
        await runtime.start()
        try:
            await runtime.add_channel(
                "webhook-dev",
                ChannelConfig(enabled=True, kind="webhook", mode="webhook", account_id="acct"),
            )
            await runtime.remove_channel("webhook-dev")
            assert "webhook-dev" not in runtime.adapters
            assert "webhook-dev" not in runtime.manager.channels
            assert runtime.states["webhook-dev"]["state"] == "removed"
        finally:
            await runtime.stop()

    asyncio.run(run())
```

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

Run:

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

Expected: fail with `AttributeError: 'ChannelRuntime' object has no attribute 'add_channel'`.

- [ ] **Step 3: Add dynamic manager methods**

Modify `app-instance/backend/beaver/interfaces/channels/manager.py`:

```python
    def register(self, channel: ChannelAdapter) -> None:
        if channel.channel_id in self.channels:
            raise ValueError(f"Channel already registered: {channel.channel_id}")
        self.channels[channel.channel_id] = channel

    def unregister(self, channel_id: str) -> ChannelAdapter | None:
        return self.channels.pop(channel_id, None)

    def replace_registered(self, channel: ChannelAdapter) -> ChannelAdapter | None:
        old = self.channels.get(channel.channel_id)
        self.channels[channel.channel_id] = channel
        return old
```

Keep `start()`, `stop()`, and `dispatch_outbound()` unchanged.

- [ ] **Step 4: Add runtime lifecycle lock and methods**

Modify `app-instance/backend/beaver/interfaces/channels/runtime.py`:

```python
        self._lifecycle_lock = asyncio.Lock()
```

Add methods to `ChannelRuntime`:

```python
    async def add_channel(self, channel_id: str, config: ChannelConfig) -> None:
        async with self._lifecycle_lock:
            current = self.channel_configs.get(channel_id)
            if current == config and channel_id in self.adapters:
                return
            if not config.enabled:
                await self._remove_channel_locked(channel_id)
                self.channel_configs[channel_id] = config
                self.states[channel_id] = {"state": "disabled", "last_error": None}
                return
            adapter = self._build_adapter(channel_id, config)
            await adapter.start()
            old = self.manager.replace_registered(adapter)
            old_adapter = self.adapters.get(channel_id)
            self.adapters[channel_id] = adapter
            self.channel_configs[channel_id] = config
            self.states[channel_id] = {"state": "running", "last_error": None, "started_at": _iso_now()}
            self.events.record(channel_id=channel_id, kind="adapter_started")
            if old_adapter is not None and old_adapter is not adapter:
                await old_adapter.stop()

    async def remove_channel(self, channel_id: str) -> None:
        async with self._lifecycle_lock:
            await self._remove_channel_locked(channel_id)

    async def _remove_channel_locked(self, channel_id: str) -> None:
        adapter = self.adapters.pop(channel_id, None)
        self.manager.unregister(channel_id)
        self.channel_configs.pop(channel_id, None)
        if adapter is not None:
            await adapter.stop()
            self.events.record(channel_id=channel_id, kind="adapter_stopped")
        self.states[channel_id] = {"state": "removed", "last_error": None}
```

- [ ] **Step 5: Run dynamic runtime tests**

Run:

```bash
cd app-instance/backend
uv run pytest tests/unit/test_channel_runtime_dynamic_channels.py tests/unit/test_channel_runtime.py tests/unit/test_gateway_channels.py -q
```

Expected: all listed tests pass.

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

```bash
git add app-instance/backend/beaver/interfaces/channels/manager.py app-instance/backend/beaver/interfaces/channels/runtime.py app-instance/backend/tests/unit/test_channel_runtime_dynamic_channels.py
git commit -m "feat: support dynamic runtime channels"
```

---

### Task 3: External Connector Channel

**Files:**
- Create: `app-instance/backend/beaver/interfaces/channels/connections/sidecar_client.py`
- Create: `app-instance/backend/beaver/interfaces/channels/external_connector.py`
- Modify: `app-instance/backend/beaver/interfaces/channels/__init__.py`
- Test: `app-instance/backend/tests/unit/test_external_connector_channel.py`

- [ ] **Step 1: Write failing channel tests**

Create `app-instance/backend/tests/unit/test_external_connector_channel.py`:

```python
from __future__ import annotations

import asyncio

from beaver.foundation.events import ChannelIdentity, OutboundMessage
from beaver.interfaces.channels.external_connector import ExternalConnectorChannel, _request_id


class FakeSidecarClient:
    def __init__(self) -> None:
        self.sent: list[dict] = []

    async def send(self, payload: dict) -> dict:
        self.sent.append(payload)
        return {"ok": True, "providerMessageId": "provider-1"}


def test_external_connector_channel_sends_with_target_and_request_id() -> None:
    async def run() -> None:
        client = FakeSidecarClient()
        channel = ExternalConnectorChannel(
            channel_id="weixin-main",
            platform_kind="weixin",
            connection_id="conn_1",
            account_id="weixin:me",
            display_name="Weixin Main",
            sidecar_client=client,
        )
        message = OutboundMessage(
            channel="weixin-main",
            content="reply",
            session_id="s1",
            finish_reason="stop",
            message_id="out-msg-1",
            channel_identity=ChannelIdentity(
                channel_id="weixin-main",
                kind="weixin",
                account_id="weixin:me",
                peer_id="peer-1",
                peer_type="dm",
                thread_id=None,
                user_id="sender-1",
                message_id="in-msg-1",
            ),
        )

        await channel.send(message)

        assert client.sent == [
            {
                "requestId": "out_weixin-main:s1:out-msg-1",
                "connectionId": "conn_1",
                "channelId": "weixin-main",
                "kind": "weixin",
                "target": {"peerId": "peer-1", "peerType": "dm", "threadId": None},
                "content": "reply",
                "metadata": {"inboundMessageId": "in-msg-1", "sessionId": "s1"},
            }
        ]

    asyncio.run(run())


def test_external_connector_request_id_falls_back_when_message_id_is_none_or_blank() -> None:
    identity = ChannelIdentity(
        channel_id="weixin-main",
        kind="weixin",
        account_id="weixin:me",
        peer_id="peer-1",
        peer_type="dm",
        message_id="in-msg-1",
    )
    first = OutboundMessage(
        channel="weixin-main",
        content="same reply",
        session_id="s1",
        finish_reason="stop",
        message_id=None,  # type: ignore[arg-type]
        channel_identity=identity,
    )
    second = OutboundMessage(
        channel="weixin-main",
        content="same reply",
        session_id="s1",
        finish_reason="stop",
        message_id="",
        channel_identity=identity,
    )

    assert _request_id(first) == _request_id(second)
    assert _request_id(first).startswith("out_weixin-main:s1:")


def test_external_connector_channel_requires_identity() -> None:
    async def run() -> None:
        channel = ExternalConnectorChannel(
            channel_id="weixin-main",
            platform_kind="weixin",
            connection_id="conn_1",
            account_id="weixin:me",
            display_name="Weixin Main",
            sidecar_client=FakeSidecarClient(),
        )
        message = OutboundMessage(channel="weixin-main", content="reply", session_id="s1", finish_reason="stop")

        try:
            await channel.send(message)
        except ValueError as exc:
            assert "channel_identity is required" in str(exc)
        else:
            raise AssertionError("Expected ValueError")

    asyncio.run(run())
```

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

Run:

```bash
cd app-instance/backend
uv run pytest tests/unit/test_external_connector_channel.py -q
```

Expected: fail with `ModuleNotFoundError: No module named 'beaver.interfaces.channels.external_connector'`.

- [ ] **Step 3: Implement sidecar client**

Create `app-instance/backend/beaver/interfaces/channels/connections/sidecar_client.py`:

```python
from __future__ import annotations

import hashlib
from typing import Any

import httpx


class ConnectorSidecarClient:
    def __init__(self, *, base_url: str, token: str, timeout_seconds: float = 20.0) -> None:
        self.base_url = base_url.rstrip("/")
        self.token = token
        self.timeout_seconds = float(timeout_seconds)

    async def get_connectors(self) -> list[dict[str, Any]]:
        return await self._request("GET", "/connectors")

    async def start_session(self, payload: dict[str, Any]) -> dict[str, Any]:
        return await self._request("POST", "/connector-sessions", json=payload)

    async def get_session(self, session_id: str) -> dict[str, Any]:
        return await self._request("GET", f"/connector-sessions/{session_id}")

    async def cancel_session(self, session_id: str) -> dict[str, Any]:
        return await self._request("POST", f"/connector-sessions/{session_id}/cancel", json={})

    async def logout(self, connection_id: str) -> dict[str, Any]:
        return await self._request("POST", f"/connections/{connection_id}/logout", json={})

    async def send(self, payload: dict[str, Any]) -> dict[str, Any]:
        return await self._request("POST", "/send", json=payload)

    async def _request(self, method: str, path: str, *, json: dict[str, Any] | None = None) -> Any:
        headers = {"Authorization": f"Bearer {self.token}"} if self.token else {}
        async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
            response = await client.request(method, f"{self.base_url}{path}", json=json, headers=headers)
        response.raise_for_status()
        return response.json()
```

- [ ] **Step 4: Implement external channel**

Create `app-instance/backend/beaver/interfaces/channels/external_connector.py`:

```python
from __future__ import annotations

from typing import Any

from beaver.foundation.events import OutboundMessage
from beaver.interfaces.channels.connections.sidecar_client import ConnectorSidecarClient


class ExternalConnectorChannel:
    def __init__(
        self,
        *,
        channel_id: str,
        platform_kind: str,
        connection_id: str,
        account_id: str,
        display_name: str,
        sidecar_client: ConnectorSidecarClient | Any,
    ) -> None:
        self.channel_id = channel_id
        self.kind = "external_connector"
        self.mode = "http"
        self.platform_kind = platform_kind
        self.connection_id = connection_id
        self.account_id = account_id
        self.display_name = display_name or channel_id
        self.sidecar_client = sidecar_client
        self.started = False

    async def start(self) -> None:
        self.started = True

    async def stop(self) -> None:
        self.started = False

    async def send(self, message: OutboundMessage) -> None:
        identity = message.channel_identity
        if identity is None:
            raise ValueError("channel_identity is required for external connector sends")
        payload = {
            "requestId": _request_id(message),
            "connectionId": self.connection_id,
            "channelId": self.channel_id,
            "kind": self.platform_kind,
            "target": {
                "peerId": identity.peer_id,
                "peerType": identity.peer_type,
                "threadId": identity.thread_id,
            },
            "content": message.content,
            "metadata": {
                "inboundMessageId": identity.message_id,
                "sessionId": message.session_id,
            },
        }
        await self.sidecar_client.send(payload)


def _request_id(message: OutboundMessage) -> str:
    identity = message.channel_identity
    channel = message.channel or (identity.channel_id if identity else "unknown")
    session_id = message.session_id or (identity.session_id() if identity else "unknown")
    message_id = str(message.message_id or "").strip()
    if not message_id:
        basis = "|".join(
            [
                message.content,
                identity.message_id if identity and identity.message_id else "",
                identity.peer_id if identity else "",
                message.finish_reason,
            ]
        )
        message_id = hashlib.sha256(basis.encode("utf-8")).hexdigest()[:24]
    return f"out_{channel}:{session_id}:{message_id}"
```

- [ ] **Step 5: Export channel symbol**

Modify `app-instance/backend/beaver/interfaces/channels/__init__.py`:

```python
from .external_connector import ExternalConnectorChannel
```

Add `ExternalConnectorChannel` to `__all__`.

- [ ] **Step 6: Run channel tests**

Run:

```bash
cd app-instance/backend
uv run pytest tests/unit/test_external_connector_channel.py -q
```

Expected: `2 passed`.

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

```bash
git add app-instance/backend/beaver/interfaces/channels/connections/sidecar_client.py app-instance/backend/beaver/interfaces/channels/external_connector.py app-instance/backend/beaver/interfaces/channels/__init__.py app-instance/backend/tests/unit/test_external_connector_channel.py
git commit -m "feat: add external connector channel"
```

---

### Task 4: Runtime Factory For External Connector Channel

**Files:**
- Modify: `app-instance/backend/beaver/interfaces/channels/runtime.py`
- Test: `app-instance/backend/tests/unit/test_channel_runtime_dynamic_channels.py`

- [ ] **Step 1: Extend dynamic runtime tests**

Append to `app-instance/backend/tests/unit/test_channel_runtime_dynamic_channels.py`:

```python
def test_runtime_builds_external_connector_channel(tmp_path, monkeypatch) -> None:
    async def run() -> None:
        monkeypatch.setenv("EXTERNAL_CONNECTOR_TOKEN", "connector-token")
        runtime = ChannelRuntime(service=FakeService(), workspace=tmp_path, channels={}, bus=MessageBus())
        await runtime.start()
        try:
            await runtime.add_channel(
                "weixin-main",
                ChannelConfig(
                    enabled=True,
                    kind="external_connector",
                    mode="http",
                    account_id="weixin:me",
                    display_name="Weixin Main",
                    config={
                        "platformKind": "weixin",
                        "connectionId": "conn_1",
                        "sidecarBaseUrl": "http://external-connector:8787",
                    },
                ),
            )
            adapter = runtime.adapters["weixin-main"]
            assert adapter.kind == "external_connector"
            assert adapter.mode == "http"
            assert getattr(adapter, "platform_kind") == "weixin"
        finally:
            await runtime.stop()

    asyncio.run(run())
```

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

Run:

```bash
cd app-instance/backend
uv run pytest tests/unit/test_channel_runtime_dynamic_channels.py::test_runtime_builds_external_connector_channel -q
```

Expected: fail with `ValueError: Unsupported channel kind/mode: external_connector/http`.

- [ ] **Step 3: Add runtime factory branch**

Modify `_build_adapter()` in `app-instance/backend/beaver/interfaces/channels/runtime.py` before the final `raise`:

```python
        if cfg.kind == "external_connector" and cfg.mode == "http":
            import os

            from beaver.interfaces.channels.connections.sidecar_client import ConnectorSidecarClient
            from beaver.interfaces.channels.external_connector import ExternalConnectorChannel

            base_url = str(cfg.config.get("sidecarBaseUrl") or os.getenv("EXTERNAL_CONNECTOR_BASE_URL") or "").strip()
            token = os.getenv("EXTERNAL_CONNECTOR_TOKEN", "")
            platform_kind = str(cfg.config.get("platformKind") or "").strip()
            connection_id = str(cfg.config.get("connectionId") or "").strip()
            if not base_url:
                raise ValueError("external connector sidecarBaseUrl is required")
            if not platform_kind:
                raise ValueError("external connector platformKind is required")
            if not connection_id:
                raise ValueError("external connector connectionId is required")
            return ExternalConnectorChannel(
                channel_id=channel_id,
                platform_kind=platform_kind,
                connection_id=connection_id,
                account_id=cfg.account_id,
                display_name=cfg.display_name,
                sidecar_client=ConnectorSidecarClient(base_url=base_url, token=token),
            )
```

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

Run:

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

Expected: all listed tests pass.

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

```bash
git add app-instance/backend/beaver/interfaces/channels/runtime.py app-instance/backend/tests/unit/test_channel_runtime_dynamic_channels.py
git commit -m "feat: materialize external connector channels"
```

---

### Task 5: Bridge API

**Files:**
- Modify: `app-instance/backend/beaver/interfaces/web/schemas/chat.py`
- Modify: `app-instance/backend/beaver/interfaces/web/schemas/__init__.py`
- Modify: `app-instance/backend/beaver/interfaces/web/app.py`
- Test: `app-instance/backend/tests/unit/test_external_connector_bridge_api.py`

- [ ] **Step 1: Write failing bridge API tests**

Create `app-instance/backend/tests/unit/test_external_connector_bridge_api.py`:

```python
from __future__ import annotations

from fastapi.testclient import TestClient

from beaver.interfaces.channels.connections import ChannelConnectionStore, CredentialStore
from beaver.interfaces.web.app import create_app
from beaver.services.agent_service import AgentService


def _app(tmp_path, monkeypatch):
    monkeypatch.setenv("BEAVER_BRIDGE_TOKEN", "bridge-token")
    config_path = tmp_path / "config.json"
    config_path.write_text(
        '{"agents": {"defaults": {"workspace": "%s"}}, "providers": {}}' % str(tmp_path),
        encoding="utf-8",
    )
    service = AgentService(config_path=config_path)
    app = create_app(service=service, manage_service_lifecycle=False)
    return app, service


def test_bridge_endpoint_accepts_valid_event(tmp_path, monkeypatch) -> None:
    app, service = _app(tmp_path, monkeypatch)
    state_dir = tmp_path / "state" / "channel_connections"
    store = ChannelConnectionStore(state_dir / "connections.json")
    connection = store.create(
        kind="weixin",
        mode="sidecar",
        display_name="Weixin Main",
        account_id="weixin:me",
        owner_user_id=None,
        auth_type="connector_session",
    )
    store.update_status(connection.connection_id, status="connected", last_error=None)
    try:
        with TestClient(app) as client:
            response = client.post(
                "/api/channel-connector-bridge/events",
                headers={"Authorization": "Bearer bridge-token"},
                json={
                    "eventId": "evt-1",
                    "timestamp": "2026-06-02T09:30:00Z",
                    "deliveryAttempt": 1,
                    "connectionId": connection.connection_id,
                    "channelId": connection.channel_id,
                    "kind": "weixin",
                    "accountId": "weixin:me",
                    "peerId": "peer-1",
                    "peerType": "dm",
                    "userId": "sender-1",
                    "threadId": None,
                    "messageId": "msg-1",
                    "messageType": "text",
                    "content": "hello",
                    "metadata": {},
                },
            )
        assert response.status_code == 200
        assert response.json()["accepted"] is True
    finally:
        service.close()


def test_bridge_endpoint_rejects_invalid_token(tmp_path, monkeypatch) -> None:
    app, service = _app(tmp_path, monkeypatch)
    try:
        with TestClient(app) as client:
            response = client.post("/api/channel-connector-bridge/events", headers={"Authorization": "Bearer wrong"}, json={})
        assert response.status_code == 401
    finally:
        service.close()


def test_bridge_endpoint_returns_conflict_for_processing_duplicate(tmp_path, monkeypatch) -> None:
    app, service = _app(tmp_path, monkeypatch)
    state_dir = tmp_path / "state" / "channel_connections"
    store = ChannelConnectionStore(state_dir / "connections.json")
    connection = store.create(
        kind="weixin",
        mode="sidecar",
        display_name="Weixin Main",
        account_id="weixin:me",
        owner_user_id=None,
        auth_type="connector_session",
    )
    store.update_status(connection.connection_id, status="connected", last_error=None)
    payload = {
        "eventId": "evt-1",
        "timestamp": "2026-06-02T09:30:00Z",
        "deliveryAttempt": 1,
        "connectionId": connection.connection_id,
        "channelId": connection.channel_id,
        "kind": "weixin",
        "accountId": "weixin:me",
        "peerId": "peer-1",
        "peerType": "dm",
        "userId": "sender-1",
        "threadId": None,
        "messageId": "msg-1",
        "messageType": "text",
        "content": "hello",
        "metadata": {},
    }
    try:
        with TestClient(app) as client:
            first = client.post("/api/channel-connector-bridge/events", headers={"Authorization": "Bearer bridge-token"}, json=payload)
            second = client.post("/api/channel-connector-bridge/events", headers={"Authorization": "Bearer bridge-token"}, json={**payload, "deliveryAttempt": 2})
        assert first.status_code == 200
        assert second.status_code in {200, 409}
    finally:
        service.close()
```

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

Run:

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

Expected: fail with `404 Not Found` for `/api/channel-connector-bridge/events`.

- [ ] **Step 3: Add bridge schemas**

Append to `app-instance/backend/beaver/interfaces/web/schemas/chat.py`:

```python
class WebConnectorBridgeEventRequest(BaseModel):
    event_id: str = Field(alias="eventId")
    timestamp: str
    delivery_attempt: int = Field(default=1, alias="deliveryAttempt")
    connection_id: str = Field(alias="connectionId")
    channel_id: str = Field(alias="channelId")
    kind: str
    account_id: str = Field(alias="accountId")
    peer_id: str = Field(alias="peerId")
    peer_type: str = Field(default="unknown", alias="peerType")
    user_id: str | None = Field(default=None, alias="userId")
    thread_id: str | None = Field(default=None, alias="threadId")
    message_id: str = Field(alias="messageId")
    message_type: str = Field(default="text", alias="messageType")
    content: str
    metadata: dict[str, Any] = Field(default_factory=dict)


class WebConnectorBridgeEventResponse(BaseModel):
    accepted: bool
    duplicate: bool = False
    pending: bool = False
    retry_after_seconds: int | None = Field(default=None, alias="retryAfterSeconds")
```

Export both in `app-instance/backend/beaver/interfaces/web/schemas/__init__.py`.

- [ ] **Step 4: Add bridge endpoint**

Modify `app-instance/backend/beaver/interfaces/web/app.py` imports to include:

```python
from beaver.foundation.events import ChannelIdentity, InboundMessage
from beaver.interfaces.channels.connections import MessageDedupeStore
```

Add helpers:

```python
def _bridge_token() -> str:
    return os.getenv("BEAVER_BRIDGE_TOKEN", "")


def _message_dedupe_store(workspace: Path) -> MessageDedupeStore:
    return MessageDedupeStore(_connection_state_dir(workspace) / "message_dedupe.json")
```

Add the route inside `create_app()`:

```python
    @app.post("/api/channel-connector-bridge/events", response_model=WebConnectorBridgeEventResponse)
    async def accept_connector_bridge_event(
        request: Request,
        payload: WebConnectorBridgeEventRequest,
        authorization: str | None = Header(default=None),
    ) -> JSONResponse | WebConnectorBridgeEventResponse:
        expected = _bridge_token()
        if not expected or authorization != f"Bearer {expected}":
            raise HTTPException(status_code=401, detail="Invalid connector bridge token")
        registry = get_channel_connector_registry(request)
        try:
            connection = registry.connection_store.get(payload.connection_id)
        except KeyError:
            raise HTTPException(status_code=404, detail="Channel connection not found")
        if connection.status == "revoked":
            raise HTTPException(status_code=404, detail="Channel connection not found")
        store = _message_dedupe_store(_channel_connection_workspace(request))
        begin = store.begin(
            connection_id=payload.connection_id,
            event_id=payload.event_id,
            delivery_attempt=payload.delivery_attempt,
        )
        if not begin.should_process:
            body = WebConnectorBridgeEventResponse(
                accepted=begin.http_status == 200,
                duplicate=True,
                pending=begin.http_status == 409,
                retryAfterSeconds=begin.retry_after_seconds,
            ).model_dump(by_alias=True)
            return JSONResponse(status_code=begin.http_status, content=body)
        runtime = get_channel_runtime(request)
        identity = ChannelIdentity(
            channel_id=payload.channel_id,
            kind=payload.kind,
            account_id=payload.account_id,
            peer_id=payload.peer_id,
            thread_id=payload.thread_id,
            peer_type=payload.peer_type,
            user_id=payload.user_id,
            message_id=payload.message_id,
        )
        inbound = InboundMessage(
            channel=payload.channel_id,
            content=payload.content,
            content_type=payload.message_type,
            channel_identity=identity,
            user_id=payload.user_id,
            message_id=payload.message_id,
            metadata=dict(payload.metadata),
        )
        result = await runtime.accept_inbound(inbound)
        if result.accepted:
            store.complete(begin.dedupe_key, message_id=payload.message_id)
        else:
            store.fail(begin.dedupe_key, error=result.error or "runtime rejected bridge event")
        return WebConnectorBridgeEventResponse(accepted=result.accepted, duplicate=result.duplicate, pending=result.pending)
```

If `_channel_connection_workspace(request)` does not exist yet, add it:

```python
def _channel_connection_workspace(request: Request) -> Path:
    workspace = getattr(request.app.state, "channel_connection_workspace", None)
    if workspace is not None:
        return Path(workspace)
    return Path(get_agent_service(request).loader.workspace)
```

- [ ] **Step 5: Run bridge tests**

Run:

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

Expected: all listed tests pass. If the duplicate test returns `200` because runtime completes before the second request, add a focused `MessageDedupeStore` API test for `409`; do not weaken the store behavior.

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

```bash
git add app-instance/backend/beaver/interfaces/web/app.py app-instance/backend/beaver/interfaces/web/schemas/chat.py app-instance/backend/beaver/interfaces/web/schemas/__init__.py app-instance/backend/tests/unit/test_external_connector_bridge_api.py
git commit -m "feat: add external connector bridge api"
```

---

### Task 6: Weixin And Feishu Connectors

**Files:**
- Create: `app-instance/backend/beaver/interfaces/channels/connections/external.py`
- Modify: `app-instance/backend/beaver/interfaces/channels/connections/__init__.py`
- Modify: `app-instance/backend/beaver/interfaces/channels/connections/connectors.py`
- Modify: `app-instance/backend/beaver/interfaces/web/schemas/chat.py`
- Modify: `app-instance/backend/beaver/interfaces/web/schemas/__init__.py`
- Modify: `app-instance/backend/beaver/interfaces/web/app.py`
- Test: `app-instance/backend/tests/unit/test_external_sidecar_connectors.py`

- [ ] **Step 1: Write failing connector tests**

Create `app-instance/backend/tests/unit/test_external_sidecar_connectors.py`:

```python
from __future__ import annotations

import asyncio

from beaver.interfaces.channels.connections import (
    ChannelConnectionStore,
    CredentialStore,
    FeishuConnector,
    WeixinConnector,
)


class FakeSidecarClient:
    def __init__(self) -> None:
        self.sessions: dict[str, dict] = {}
        self.started: list[dict] = []
        self.logged_out: list[str] = []

    async def start_session(self, payload: dict) -> dict:
        self.started.append(payload)
        session = {
            "sessionId": "cs_1",
            "kind": payload["kind"],
            "status": "qr_ready",
            "qrImage": "data:image/png;base64,abc",
            "accountId": None,
            "displayName": None,
            "metadata": {},
        }
        self.sessions["cs_1"] = session
        return session

    async def get_session(self, session_id: str) -> dict:
        return self.sessions[session_id]

    async def logout(self, connection_id: str) -> dict:
        self.logged_out.append(connection_id)
        return {"ok": True}


def test_weixin_connector_starts_connector_session(tmp_path) -> None:
    async def run() -> None:
        connection_store = ChannelConnectionStore(tmp_path / "connections.json")
        credential_store = CredentialStore(tmp_path / "credentials.json")
        client = FakeSidecarClient()
        connector = WeixinConnector(
            connection_store=connection_store,
            credential_store=credential_store,
            sidecar_client=client,
            sidecar_base_url="http://external-connector:8787",
        )

        view = await connector.start_session(display_name="Weixin Main", owner_user_id="user-1", options={})

        assert view["sessionId"] == "cs_1"
        assert client.started[0]["kind"] == "weixin"
        assert client.started[0]["connectionId"].startswith("conn_")
        assert connection_store.list()[0].kind == "weixin"
        assert connection_store.list()[0].status == "pairing"

    asyncio.run(run())


def test_weixin_connector_poll_connected_materializes_external_runtime(tmp_path) -> None:
    async def run() -> None:
        connection_store = ChannelConnectionStore(tmp_path / "connections.json")
        credential_store = CredentialStore(tmp_path / "credentials.json")
        client = FakeSidecarClient()
        connector = WeixinConnector(
            connection_store=connection_store,
            credential_store=credential_store,
            sidecar_client=client,
            sidecar_base_url="http://external-connector:8787",
        )
        await connector.start_session(display_name="Weixin Main", owner_user_id=None, options={})
        connection = connection_store.list()[0]
        client.sessions["cs_1"] = {
            "sessionId": "cs_1",
            "kind": "weixin",
            "status": "connected",
            "accountId": "weixin:me",
            "displayName": "Me",
            "metadata": {"stateRef": "state-1"},
        }

        result = await connector.poll_session("cs_1")
        updated = connection_store.get(connection.connection_id)
        spec = await connector.materialize_runtime(connection.connection_id)

        assert result["status"] == "connected"
        assert updated.status == "connected"
        assert updated.account_id == "weixin:me"
        assert spec.kind == "external_connector"
        assert spec.mode == "http"
        assert spec.config["platformKind"] == "weixin"
        assert spec.config["sidecarBaseUrl"] == "http://external-connector:8787"

    asyncio.run(run())


def test_feishu_connector_uses_feishu_kind(tmp_path) -> None:
    async def run() -> None:
        connection_store = ChannelConnectionStore(tmp_path / "connections.json")
        credential_store = CredentialStore(tmp_path / "credentials.json")
        client = FakeSidecarClient()
        connector = FeishuConnector(
            connection_store=connection_store,
            credential_store=credential_store,
            sidecar_client=client,
            sidecar_base_url="http://external-connector:8787",
        )

        await connector.start_session(display_name="Feishu Main", owner_user_id=None, options={"domain": "feishu"})

        assert client.started[0]["kind"] == "feishu"
        assert client.started[0]["options"] == {"domain": "feishu"}

    asyncio.run(run())
```

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

Run:

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

Expected: fail with `ImportError: cannot import name 'WeixinConnector'`.

- [ ] **Step 3: Implement connector classes**

Create `app-instance/backend/beaver/interfaces/channels/connections/external.py`:

```python
from __future__ import annotations

from typing import Any

from .models import ChannelRuntimeSpec, ValidationResult
from .sidecar_client import ConnectorSidecarClient
from .store import ChannelConnectionStore, CredentialStore


class ExternalConnectorBase:
    kind = ""
    capabilities: list[str] = []

    def __init__(
        self,
        *,
        connection_store: ChannelConnectionStore,
        credential_store: CredentialStore,
        sidecar_client: ConnectorSidecarClient | Any,
        sidecar_base_url: str,
    ) -> None:
        self.connection_store = connection_store
        self.credential_store = credential_store
        self.sidecar_client = sidecar_client
        self.sidecar_base_url = sidecar_base_url

    async def start_session(
        self,
        *,
        display_name: str,
        owner_user_id: str | None,
        options: dict[str, Any],
    ) -> dict[str, Any]:
        connection = self.connection_store.create(
            kind=self.kind,
            mode="sidecar",
            display_name=display_name or self.kind,
            account_id="",
            owner_user_id=owner_user_id,
            auth_type="connector_session",
            runtime_config={"sidecarBaseUrl": self.sidecar_base_url},
            capabilities=list(self.capabilities),
        )
        self.connection_store.update_status(connection.connection_id, status="pairing", last_error=None)
        payload = {
            "kind": self.kind,
            "connectionId": connection.connection_id,
            "channelId": connection.channel_id,
            "displayName": connection.display_name,
            "callbackBaseUrl": "",
            "options": dict(options),
        }
        view = await self.sidecar_client.start_session(payload)
        connection.pairing_session_id = str(view.get("sessionId") or "")
        self.connection_store.update(connection)
        return view

    async def poll_session(self, session_id: str) -> dict[str, Any]:
        view = await self.sidecar_client.get_session(session_id)
        connection = self._connection_for_session(session_id)
        status = str(view.get("status") or "")
        if status == "connected":
            connection.account_id = str(view.get("accountId") or connection.account_id)
            connection.display_name = str(view.get("displayName") or connection.display_name)
            metadata = view.get("metadata") if isinstance(view.get("metadata"), dict) else {}
            state_ref = metadata.get("stateRef")
            if state_ref:
                connection.credentials_ref = self.credential_store.put(kind=self.kind, values={"stateRef": state_ref})
            self.connection_store.update(connection)
            self.connection_store.update_status(connection.connection_id, status="connected", last_error=None)
        elif status in {"expired", "error", "cancelled"}:
            self.connection_store.update_status(connection.connection_id, status="error", last_error=str(view.get("error") or status))
        return view

    async def validate(self, connection_id: str) -> ValidationResult:
        connection = self.connection_store.get(connection_id)
        if connection.status in {"connected", "running"}:
            return ValidationResult(ok=True, status="connected", account_id=connection.account_id, display_name=connection.display_name)
        return ValidationResult(ok=False, status=connection.status, error=connection.last_error)

    async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec:
        connection = self.connection_store.get(connection_id)
        return ChannelRuntimeSpec(
            channel_id=connection.channel_id,
            kind="external_connector",
            mode="http",
            account_id=connection.account_id,
            display_name=connection.display_name,
            config={
                "platformKind": self.kind,
                "connectionId": connection.connection_id,
                "sidecarBaseUrl": connection.runtime_config.get("sidecarBaseUrl") or self.sidecar_base_url,
            },
            secrets_ref=None,
        )

    async def revoke(self, connection_id: str) -> None:
        await self.sidecar_client.logout(connection_id)

    def _connection_for_session(self, session_id: str):
        for connection in self.connection_store.list():
            if connection.pairing_session_id == session_id:
                return connection
        raise KeyError(session_id)


class WeixinConnector(ExternalConnectorBase):
    kind = "weixin"
    capabilities = ["receive_text", "send_text", "receive_media", "direct_messages"]


class FeishuConnector(ExternalConnectorBase):
    kind = "feishu"
    capabilities = ["receive_text", "send_text", "receive_media", "groups"]
```

- [ ] **Step 4: Export connectors and update registry materialization**

Modify `app-instance/backend/beaver/interfaces/channels/connections/__init__.py`:

```python
from .external import ExternalConnectorBase, FeishuConnector, WeixinConnector
```

Add names to `__all__`.

Ensure `ChannelConnectorRegistry.materialize_channel_configs()` accepts `ChannelRuntimeSpec(kind="external_connector", mode="http")` and emits `ChannelConfig(secrets={})`.

- [ ] **Step 5: Add connector session web schemas**

Append to `app-instance/backend/beaver/interfaces/web/schemas/chat.py`:

```python
class WebConnectorSessionCreateRequest(BaseModel):
    kind: str
    display_name: str | None = Field(default=None, alias="displayName")
    owner_user_id: str | None = Field(default=None, alias="ownerUserId")
    options: dict[str, Any] = Field(default_factory=dict)


class WebConnectorSessionResponse(BaseModel):
    session: dict[str, Any]
    connection: dict[str, Any] | None = None
```

Export both in `app-instance/backend/beaver/interfaces/web/schemas/__init__.py`.

- [ ] **Step 6: Register Weixin/Feishu in app registry**

Modify `_build_channel_connector_registry()` in `app-instance/backend/beaver/interfaces/web/app.py` to create a sidecar client:

```python
    sidecar_base_url = os.getenv("EXTERNAL_CONNECTOR_BASE_URL", "http://external-connector:8787")
    sidecar_token = os.getenv("EXTERNAL_CONNECTOR_TOKEN", "")
    sidecar_client = ConnectorSidecarClient(base_url=sidecar_base_url, token=sidecar_token)
    registry.register(WeixinConnector(
        connection_store=connection_store,
        credential_store=credential_store,
        sidecar_client=sidecar_client,
        sidecar_base_url=sidecar_base_url,
    ))
    registry.register(FeishuConnector(
        connection_store=connection_store,
        credential_store=credential_store,
        sidecar_client=sidecar_client,
        sidecar_base_url=sidecar_base_url,
    ))
```

Keep Telegram registration unchanged.

- [ ] **Step 7: Add connector session API routes**

Add routes near existing channel connection APIs in `app-instance/backend/beaver/interfaces/web/app.py`:

```python
    @app.post("/api/channel-connector-sessions", response_model=WebConnectorSessionResponse)
    async def start_channel_connector_session(
        request: Request,
        payload: WebConnectorSessionCreateRequest,
    ) -> WebConnectorSessionResponse:
        registry = get_channel_connector_registry(request)
        connector = registry.connector_for_kind(_clean_text(payload.kind))
        if not hasattr(connector, "start_session"):
            raise HTTPException(status_code=400, detail="Connector does not support sessions")
        view = await connector.start_session(
            display_name=_clean_text(payload.display_name) or _clean_text(payload.kind),
            owner_user_id=_clean_text(payload.owner_user_id) or None,
            options=payload.options,
        )
        connection = registry.connection_store.get(str(view.get("connectionId") or registry.connection_store.list()[-1].connection_id))
        return WebConnectorSessionResponse(session=view, connection=_connection_response_view(connection))

    @app.get("/api/channel-connector-sessions/{session_id}", response_model=WebConnectorSessionResponse)
    async def get_channel_connector_session(session_id: str, request: Request) -> WebConnectorSessionResponse:
        registry = get_channel_connector_registry(request)
        connection = next((item for item in registry.connection_store.list() if item.pairing_session_id == session_id), None)
        if connection is None:
            raise HTTPException(status_code=404, detail="Connector session not found")
        connector = registry.connector_for_kind(connection.kind)
        if not hasattr(connector, "poll_session"):
            raise HTTPException(status_code=400, detail="Connector does not support sessions")
        view = await connector.poll_session(session_id)
        connection = registry.connection_store.get(connection.connection_id)
        if connection.status == "connected":
            runtime = get_channel_runtime(request)
            config = (await registry.materialize_channel_configs())[connection.channel_id]
            await runtime.add_channel(connection.channel_id, config)
        return WebConnectorSessionResponse(session=view, connection=_connection_response_view(connection))
```

Add `connector_for_kind()` to `ChannelConnectorRegistry`:

```python
    def connector_for_kind(self, kind: str) -> ChannelConnector:
        return self._connector(kind)
```

- [ ] **Step 8: Run connector tests**

Run:

```bash
cd app-instance/backend
uv run pytest tests/unit/test_external_sidecar_connectors.py tests/unit/test_channel_connection_api.py tests/unit/test_channel_connector_registry.py -q
```

Expected: all listed tests pass.

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

```bash
git add app-instance/backend/beaver/interfaces/channels/connections app-instance/backend/beaver/interfaces/web/app.py app-instance/backend/beaver/interfaces/web/schemas/chat.py app-instance/backend/beaver/interfaces/web/schemas/__init__.py app-instance/backend/tests/unit/test_external_sidecar_connectors.py
git commit -m "feat: add sidecar-backed channel connectors"
```

---

### Task 7: Final Backend Verification

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

- [ ] **Step 1: Run focused backend tests**

Run:

```bash
cd app-instance/backend
uv run pytest \
  tests/unit/test_connector_message_dedupe_store.py \
  tests/unit/test_external_connector_channel.py \
  tests/unit/test_channel_runtime_dynamic_channels.py \
  tests/unit/test_external_connector_bridge_api.py \
  tests/unit/test_external_sidecar_connectors.py \
  tests/unit/test_channel_connection_store.py \
  tests/unit/test_channel_connector_registry.py \
  tests/unit/test_channel_connection_api.py \
  tests/unit/test_channel_runtime.py \
  tests/unit/test_gateway_channels.py \
  -q
```

Expected: all listed tests pass.

- [ ] **Step 2: Run import tests**

Run:

```bash
cd app-instance/backend
uv run pytest tests/unit/test_imports.py -q
```

Expected: all import tests pass.

- [ ] **Step 3: Scan for leaked service tokens in implementation**

Run:

```bash
cd app-instance/backend
rg -n "bridge-token|connector-token|token-1|token-2|secret-token" beaver || true
```

Expected: no implementation files contain fixture token values.

- [ ] **Step 4: Commit verification-only fixes if needed**

If Step 1 or Step 2 required a small fix, commit it:

```bash
git add app-instance/backend
git commit -m "fix: stabilize external connector backend runtime"
```

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