# Channel Connectors Foundation 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:** Build the first connector slice: durable channel connections, pairing/credential primitives, connector registry, Telegram token connector, and backend APIs that materialize a runtime channel without manual JSON editing.

**Architecture:** Add a connector layer under `beaver/interfaces/channels/connections/` while keeping `ChannelRuntime`, `MessageBus`, and existing adapters as the message path. A `ChannelConnectionStore` persists setup state, a small credential vault stores secrets by reference, and `ChannelConnectorRegistry` materializes enabled connections into `ChannelConfig` objects during app startup. The first concrete connector is Telegram because token validation and runtime materialization are simple and testable with fake clients.

**Weixin Follow-Up Constraint:** Weixin personal-account support is not implemented in this foundation slice. The follow-up Weixin plan must use a docker-compose predeclared sidecar service and Beaver must only call the existing connector HTTP API; Beaver must not dynamically create containers or require Docker socket access. Because the sidecar owns Weixin protocol, QR login, receive, and send behavior, Beaver should expose it to `ChannelRuntime` as an `ExternalConnectorChannel`, not as a protocol-level `WeixinAdapter`.

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

---

## Scope

This plan implements phase 1 of `docs/superpowers/specs/2026-06-02-channel-connectors-and-pairing-design.md`.

Included:

- `ChannelConnection` data model and persistent JSON store.
- Restricted local credential store with secret redaction.
- One-time pairing token store, used now by tests and future terminal/QR connectors. It is implemented in this phase but not exposed through APIs; future terminal and QR connectors will consume it.
- Connector protocol and registry.
- Telegram connector with fake-client test hooks.
- Connection control APIs.
- App startup materialization from connections into `ChannelRuntime`.

Excluded from this plan:

- Terminal authenticated pairing.
- Feishu/Lark official SDK integration.
- Weixin docker-compose sidecar pairing and bridge implementation. The later Weixin plan must use a predeclared sidecar service plus Beaver HTTP bridge endpoints, not local host dependencies, dynamic container creation, or a Beaver-owned Weixin protocol adapter.
- QQBot connector.
- Frontend connection wizard.
- Hot starting/stopping adapters without backend restart.
- Multi-process-safe storage. The JSON stores use `threading.Lock` plus atomic file replace for the single backend process used in phase 1. Production multi-worker deployment needs a file lock or database-backed store.
- Credential garbage collection. Updating secrets writes a new credential reference and leaves the old reference in the local credential file until a later cleanup pass.

## File Structure

- Create `app-instance/backend/beaver/interfaces/channels/connections/__init__.py`
  - Exports connection models, stores, connector registry, and Telegram connector.
- Create `app-instance/backend/beaver/interfaces/channels/connections/models.py`
  - Dataclasses and constants for `ChannelConnection`, `PairingSession`, `ChannelRuntimeSpec`, `ValidationResult`.
- Create `app-instance/backend/beaver/interfaces/channels/connections/store.py`
  - JSON-backed `ChannelConnectionStore`, `CredentialStore`, and `PairingTokenStore`.
- Create `app-instance/backend/beaver/interfaces/channels/connections/connectors.py`
  - `ChannelConnector` protocol and `ChannelConnectorRegistry`.
- Create `app-instance/backend/beaver/interfaces/channels/connections/telegram.py`
  - Telegram token connector that validates via injected client factory and materializes a runtime spec.
- Modify `app-instance/backend/beaver/interfaces/web/schemas/chat.py`
  - Add Pydantic request/response models for connection APIs.
- Modify `app-instance/backend/beaver/interfaces/web/schemas/__init__.py`
  - Export the new schemas.
- Modify `app-instance/backend/beaver/interfaces/web/app.py`
  - Instantiate connection stores/registry, expose `/api/channel-connectors` and `/api/channel-connections` APIs, and merge materialized connection configs into runtime startup.
- Create `app-instance/backend/tests/unit/test_channel_connection_store.py`
  - Store, credential redaction, and pairing token tests.
- Create `app-instance/backend/tests/unit/test_channel_connector_registry.py`
  - Registry dispatch and runtime materialization tests.
- Create `app-instance/backend/tests/unit/test_telegram_channel_connector.py`
  - Telegram validation/materialization tests with fake client.
- Create `app-instance/backend/tests/unit/test_channel_connection_api.py`
  - FastAPI endpoint tests with fake service/app context.

---

### Task 1: Connection Models And Store

**Files:**
- Create: `app-instance/backend/beaver/interfaces/channels/connections/__init__.py`
- Create: `app-instance/backend/beaver/interfaces/channels/connections/models.py`
- Create: `app-instance/backend/beaver/interfaces/channels/connections/store.py`
- Test: `app-instance/backend/tests/unit/test_channel_connection_store.py`

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

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

```python
from __future__ import annotations

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


def test_channel_connection_store_creates_updates_lists_and_revokes(tmp_path) -> None:
    store = ChannelConnectionStore(tmp_path / "connections.json")

    created = store.create(
        kind="telegram",
        mode="polling",
        display_name="Telegram Main",
        account_id="telegram:bot-main",
        owner_user_id="user-1",
        auth_type="token",
        runtime_config={"max_message_chars": 4096},
        capabilities=["receive_text", "send_text"],
    )
    updated = store.update_status(created.connection_id, status="connected", last_error=None)
    revoked = store.revoke(created.connection_id)

    assert created.connection_id
    assert created.channel_id.startswith("telegram-")
    assert created.status == "draft"
    assert updated.status == "connected"
    assert revoked.status == "revoked"
    assert store.get(created.connection_id).status == "revoked"
    assert [item.connection_id for item in store.list()] == [created.connection_id]


def test_credential_store_saves_values_by_reference_and_redacts_views(tmp_path) -> None:
    store = CredentialStore(tmp_path / "credentials.json")

    ref = store.put(kind="telegram", values={"botToken": "secret-token", "empty": ""})

    assert ref.startswith("cred_")
    assert store.get(ref) == {"botToken": "secret-token"}
    assert store.redacted(ref) == {"botToken": "***"}


def test_pairing_token_store_uses_one_time_expiring_tokens(tmp_path) -> None:
    store = PairingTokenStore(tmp_path / "pairing.json")

    session = store.create(kind="terminal", ttl_seconds=60, scope="channel:pair")
    consumed = store.consume(session.token, expected_kind="terminal")
    reused = store.consume(session.token, expected_kind="terminal")

    assert session.status == "pending"
    assert consumed is not None
    assert consumed.status == "consumed"
    assert reused is None


def test_pairing_token_store_rejects_expired_tokens(tmp_path) -> None:
    store = PairingTokenStore(tmp_path / "pairing.json")

    session = store.create(kind="weixin", ttl_seconds=-1, scope="channel:pair")

    assert store.consume(session.token, expected_kind="weixin") is None
```

- [ ] **Step 2: Run tests to verify they fail**

Run:

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

Expected: fail during import with `ModuleNotFoundError: No module named 'beaver.interfaces.channels.connections'`.

- [ ] **Step 3: Implement connection dataclasses**

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

```python
"""Channel connection setup models."""

from __future__ import annotations

from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from typing import Any


CONNECTION_STATUSES = {"draft", "pairing", "connected", "running", "degraded", "error", "revoked"}


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


@dataclass(slots=True)
class ChannelConnection:
    connection_id: str
    owner_user_id: str | None
    channel_id: str
    kind: str
    mode: str
    display_name: str
    account_id: str
    status: str
    auth_type: str
    credentials_ref: str | None = None
    connector_ref: str | None = None
    pairing_session_id: str | None = None
    runtime_config: dict[str, Any] = field(default_factory=dict)
    capabilities: list[str] = field(default_factory=list)
    created_at: str = field(default_factory=iso_now)
    updated_at: str = field(default_factory=iso_now)
    last_seen_at: 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]) -> "ChannelConnection":
        return cls(
            connection_id=str(data.get("connection_id") or ""),
            owner_user_id=_optional_string(data.get("owner_user_id")),
            channel_id=str(data.get("channel_id") or ""),
            kind=str(data.get("kind") or ""),
            mode=str(data.get("mode") or ""),
            display_name=str(data.get("display_name") or ""),
            account_id=str(data.get("account_id") or ""),
            status=str(data.get("status") or "draft"),
            auth_type=str(data.get("auth_type") or ""),
            credentials_ref=_optional_string(data.get("credentials_ref")),
            connector_ref=_optional_string(data.get("connector_ref")),
            pairing_session_id=_optional_string(data.get("pairing_session_id")),
            runtime_config=dict(data.get("runtime_config") or {}),
            capabilities=[str(item) for item in data.get("capabilities") or []],
            created_at=str(data.get("created_at") or iso_now()),
            updated_at=str(data.get("updated_at") or iso_now()),
            last_seen_at=_optional_string(data.get("last_seen_at")),
            last_error=_optional_string(data.get("last_error")),
        )


@dataclass(slots=True)
class PairingSession:
    pairing_session_id: str
    kind: str
    scope: str
    token: str
    status: str
    expires_at_ms: int
    created_at_ms: int

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

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "PairingSession":
        return cls(
            pairing_session_id=str(data.get("pairing_session_id") or ""),
            kind=str(data.get("kind") or ""),
            scope=str(data.get("scope") or ""),
            token=str(data.get("token") or ""),
            status=str(data.get("status") or "pending"),
            expires_at_ms=int(data.get("expires_at_ms") or 0),
            created_at_ms=int(data.get("created_at_ms") or 0),
        )


@dataclass(slots=True)
class ChannelRuntimeSpec:
    channel_id: str
    kind: str
    mode: str
    account_id: str
    display_name: str
    config: dict[str, Any] = field(default_factory=dict)
    secrets_ref: str | None = None
    external_endpoint: str | None = None


@dataclass(slots=True)
class ValidationResult:
    ok: bool
    status: str
    account_id: str | None = None
    display_name: str | None = None
    error: str | None = None
    metadata: dict[str, Any] = field(default_factory=dict)


def _optional_string(value: Any) -> str | None:
    if value is None:
        return None
    text = str(value).strip()
    return text or None
```

- [ ] **Step 4: Implement JSON stores**

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

```python
"""Persistent channel connection stores."""

from __future__ import annotations

import json
import time
from pathlib import Path
from threading import Lock
from typing import Any
from uuid import uuid4

from .models import CONNECTION_STATUSES, ChannelConnection, PairingSession, iso_now


class ChannelConnectionStore:
    def __init__(self, path: Path) -> None:
        self.path = Path(path)
        self._lock = Lock()

    def create(
        self,
        *,
        kind: str,
        mode: str,
        display_name: str,
        account_id: str,
        owner_user_id: str | None,
        auth_type: str,
        runtime_config: dict[str, Any] | None = None,
        capabilities: list[str] | None = None,
        credentials_ref: str | None = None,
    ) -> ChannelConnection:
        with self._lock:
            data = self._load()
            connection_id = f"conn_{uuid4().hex}"
            channel_id = f"{_slug(kind)}-{uuid4().hex[:8]}"
            now = iso_now()
            connection = ChannelConnection(
                connection_id=connection_id,
                owner_user_id=owner_user_id,
                channel_id=channel_id,
                kind=kind,
                mode=mode,
                display_name=display_name or channel_id,
                account_id=account_id,
                status="draft",
                auth_type=auth_type,
                credentials_ref=credentials_ref,
                runtime_config=runtime_config or {},
                capabilities=capabilities or [],
                created_at=now,
                updated_at=now,
            )
            data["connections"][connection_id] = connection.to_dict()
            self._save(data)
            return connection

    def get(self, connection_id: str) -> ChannelConnection:
        data = self._load()
        raw = data["connections"].get(connection_id)
        if not isinstance(raw, dict):
            raise KeyError(connection_id)
        return ChannelConnection.from_dict(raw)

    def list(self) -> list[ChannelConnection]:
        data = self._load()
        return [ChannelConnection.from_dict(item) for item in data["connections"].values() if isinstance(item, dict)]

    def update(self, connection: ChannelConnection) -> ChannelConnection:
        with self._lock:
            data = self._load()
            if connection.connection_id not in data["connections"]:
                raise KeyError(connection.connection_id)
            connection.updated_at = iso_now()
            data["connections"][connection.connection_id] = connection.to_dict()
            self._save(data)
            return connection

    def update_status(self, connection_id: str, *, status: str, last_error: str | None) -> ChannelConnection:
        if status not in CONNECTION_STATUSES:
            raise ValueError(f"Unsupported connection status: {status}")
        connection = self.get(connection_id)
        connection.status = status
        connection.last_error = last_error
        if status in {"connected", "running"}:
            connection.last_seen_at = iso_now()
        return self.update(connection)

    def revoke(self, connection_id: str) -> ChannelConnection:
        return self.update_status(connection_id, status="revoked", last_error=None)

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


class CredentialStore:
    def __init__(self, path: Path) -> None:
        self.path = Path(path)
        self._lock = Lock()

    def put(self, *, kind: str, values: dict[str, Any]) -> str:
        cleaned = {str(key): str(value) for key, value in values.items() if str(key).strip() and str(value).strip()}
        ref = f"cred_{uuid4().hex}"
        with self._lock:
            data = self._load()
            data["credentials"][ref] = {"kind": kind, "values": cleaned, "created_at": iso_now()}
            self._save(data)
        return ref

    def get(self, ref: str) -> dict[str, str]:
        data = self._load()
        item = data["credentials"].get(ref)
        if not isinstance(item, dict):
            raise KeyError(ref)
        values = item.get("values")
        if not isinstance(values, dict):
            return {}
        return {str(key): str(value) for key, value in values.items()}

    def redacted(self, ref: str | None) -> dict[str, str]:
        if not ref:
            return {}
        try:
            values = self.get(ref)
        except KeyError:
            return {}
        return {key: "***" for key in values}

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


class PairingTokenStore:
    def __init__(self, path: Path) -> None:
        self.path = Path(path)
        self._lock = Lock()

    def create(self, *, kind: str, ttl_seconds: int, scope: str) -> PairingSession:
        now_ms = _now_ms()
        session = PairingSession(
            pairing_session_id=f"pair_{uuid4().hex}",
            kind=kind,
            scope=scope,
            token=f"pair_{uuid4().hex}",
            status="pending",
            expires_at_ms=now_ms + int(ttl_seconds * 1000),
            created_at_ms=now_ms,
        )
        with self._lock:
            data = self._load()
            data["sessions"][session.pairing_session_id] = session.to_dict()
            self._save(data)
        return session

    def consume(self, token: str, *, expected_kind: str) -> PairingSession | None:
        with self._lock:
            data = self._load()
            for key, raw in data["sessions"].items():
                session = PairingSession.from_dict(raw)
                if session.token != token or session.kind != expected_kind:
                    continue
                if session.status != "pending" or session.expires_at_ms <= _now_ms():
                    return None
                session.status = "consumed"
                data["sessions"][key] = session.to_dict()
                self._save(data)
                return session
        return None

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


def _now_ms() -> int:
    return int(time.time() * 1000)


def _slug(value: str) -> str:
    text = "".join(char if char.isalnum() else "-" for char in str(value).strip().lower())
    return "-".join(part for part in text.split("-") if part) or "channel"
```

- [ ] **Step 5: Export the connection package**

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

```python
"""Channel connection setup layer."""

from .models import ChannelConnection, ChannelRuntimeSpec, PairingSession, ValidationResult
from .store import ChannelConnectionStore, CredentialStore, PairingTokenStore

__all__ = [
    "ChannelConnection",
    "ChannelRuntimeSpec",
    "PairingSession",
    "ValidationResult",
    "ChannelConnectionStore",
    "CredentialStore",
    "PairingTokenStore",
]
```

- [ ] **Step 6: Run store tests to verify they pass**

Run:

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

Expected: `4 passed`.

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

```bash
git add app-instance/backend/beaver/interfaces/channels/connections app-instance/backend/tests/unit/test_channel_connection_store.py
git commit -m "feat: add channel connection store"
```

---

### Task 2: Connector Registry

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

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

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

```python
from __future__ import annotations

import asyncio

from beaver.interfaces.channels.connections import (
    ChannelConnectionStore,
    ChannelConnectorRegistry,
    ChannelRuntimeSpec,
    CredentialStore,
    ValidationResult,
)


class FakeConnector:
    kind = "fake"

    def __init__(self) -> None:
        self.validated: list[str] = []
        self.revoked: list[str] = []

    async def validate(self, connection_id: str) -> ValidationResult:
        self.validated.append(connection_id)
        return ValidationResult(ok=True, status="connected", account_id="fake-account", display_name="Fake")

    async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec:
        return ChannelRuntimeSpec(
            channel_id="fake-channel",
            kind="fake",
            mode="webhook",
            account_id="fake-account",
            display_name="Fake",
            config={"enabled": True},
        )

    async def revoke(self, connection_id: str) -> None:
        self.revoked.append(connection_id)
        return None


def test_connector_registry_dispatches_by_kind(tmp_path) -> None:
    async def run() -> None:
        connection_store = ChannelConnectionStore(tmp_path / "connections.json")
        credential_store = CredentialStore(tmp_path / "credentials.json")
        connector = FakeConnector()
        registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store)
        registry.register(connector)

        connection = connection_store.create(
            kind="fake",
            mode="webhook",
            display_name="Fake",
            account_id="fake-account",
            owner_user_id=None,
            auth_type="token",
        )
        result = await registry.validate(connection.connection_id)
        spec = await registry.materialize_runtime(connection.connection_id)

        assert result.ok is True
        assert connector.validated == [connection.connection_id]
        assert spec.channel_id == "fake-channel"

    asyncio.run(run())


def test_connector_registry_materializes_only_connected_connections(tmp_path) -> None:
    async def run() -> None:
        connection_store = ChannelConnectionStore(tmp_path / "connections.json")
        credential_store = CredentialStore(tmp_path / "credentials.json")
        registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store)
        registry.register(FakeConnector())

        draft = connection_store.create(
            kind="fake",
            mode="webhook",
            display_name="Draft",
            account_id="draft",
            owner_user_id=None,
            auth_type="token",
        )
        connected = connection_store.create(
            kind="fake",
            mode="webhook",
            display_name="Connected",
            account_id="connected",
            owner_user_id=None,
            auth_type="token",
        )
        connection_store.update_status(connected.connection_id, status="connected", last_error=None)

        specs = await registry.materialize_connected_runtime_specs()

        assert [spec.channel_id for spec in specs] == ["fake-channel"]
        assert connection_store.get(draft.connection_id).status == "draft"

    asyncio.run(run())


def test_connector_registry_revoke_calls_connector_and_updates_store(tmp_path) -> None:
    async def run() -> None:
        connection_store = ChannelConnectionStore(tmp_path / "connections.json")
        credential_store = CredentialStore(tmp_path / "credentials.json")
        connector = FakeConnector()
        registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store)
        registry.register(connector)

        connection = connection_store.create(
            kind="fake",
            mode="webhook",
            display_name="Fake",
            account_id="fake-account",
            owner_user_id=None,
            auth_type="token",
        )
        connection_store.update_status(connection.connection_id, status="connected", last_error=None)

        await registry.revoke(connection.connection_id)

        assert connector.revoked == [connection.connection_id]
        assert connection_store.get(connection.connection_id).status == "revoked"

    asyncio.run(run())
```

- [ ] **Step 2: Run tests to verify they fail**

Run:

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

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

- [ ] **Step 3: Implement connector protocol and registry**

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

```python
"""Channel connector registry."""

from __future__ import annotations

from typing import Protocol

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


class ChannelConnector(Protocol):
    kind: str

    async def validate(self, connection_id: str) -> ValidationResult:
        ...

    async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec:
        ...

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


class ChannelConnectorRegistry:
    def __init__(self, *, connection_store: ChannelConnectionStore, credential_store: CredentialStore) -> None:
        self.connection_store = connection_store
        self.credential_store = credential_store
        self._connectors: dict[str, ChannelConnector] = {}

    def register(self, connector: ChannelConnector) -> None:
        kind = connector.kind.strip()
        if not kind:
            raise ValueError("Connector kind is required")
        if kind in self._connectors:
            raise ValueError(f"Connector already registered: {kind}")
        self._connectors[kind] = connector

    def connectors(self) -> list[dict[str, str]]:
        return [{"kind": kind} for kind in sorted(self._connectors)]

    async def validate(self, connection_id: str) -> ValidationResult:
        connection = self.connection_store.get(connection_id)
        connector = self._connector(connection.kind)
        result = await connector.validate(connection_id)
        self.connection_store.update_status(
            connection_id,
            status=result.status,
            last_error=result.error,
        )
        return result

    async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec:
        connection = self.connection_store.get(connection_id)
        return await self._connector(connection.kind).materialize_runtime(connection_id)

    async def materialize_connected_runtime_specs(self) -> list[ChannelRuntimeSpec]:
        specs: list[ChannelRuntimeSpec] = []
        for connection in self.connection_store.list():
            if connection.status not in {"connected", "running"}:
                continue
            specs.append(await self._connector(connection.kind).materialize_runtime(connection.connection_id))
        return specs

    async def revoke(self, connection_id: str) -> None:
        connection = self.connection_store.get(connection_id)
        await self._connector(connection.kind).revoke(connection_id)
        self.connection_store.revoke(connection_id)

    def _connector(self, kind: str) -> ChannelConnector:
        connector = self._connectors.get(kind)
        if connector is None:
            raise KeyError(f"Connector not registered: {kind}")
        return connector
```

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

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

```python
"""Channel connection setup layer."""

from .connectors import ChannelConnector, ChannelConnectorRegistry
from .models import ChannelConnection, ChannelRuntimeSpec, PairingSession, ValidationResult
from .store import ChannelConnectionStore, CredentialStore, PairingTokenStore

__all__ = [
    "ChannelConnector",
    "ChannelConnectorRegistry",
    "ChannelConnection",
    "ChannelRuntimeSpec",
    "PairingSession",
    "ValidationResult",
    "ChannelConnectionStore",
    "CredentialStore",
    "PairingTokenStore",
]
```

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

Run:

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

Expected: `3 passed`.

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

```bash
git add app-instance/backend/beaver/interfaces/channels/connections app-instance/backend/tests/unit/test_channel_connector_registry.py
git commit -m "feat: add channel connector registry"
```

---

### Task 3: Telegram Connector

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

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

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

```python
from __future__ import annotations

import asyncio

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


class FakeTelegramClient:
    async def get_me(self):
        return {"id": 12345, "username": "beaver_bot", "first_name": "Beaver"}


class BrokenTelegramClient:
    async def get_me(self):
        raise RuntimeError("invalid token")


def test_telegram_connector_validates_token_and_updates_connection(tmp_path) -> None:
    async def run() -> None:
        connection_store = ChannelConnectionStore(tmp_path / "connections.json")
        credential_store = CredentialStore(tmp_path / "credentials.json")
        credentials_ref = credential_store.put(kind="telegram", values={"botToken": "token-1"})
        connection = connection_store.create(
            kind="telegram",
            mode="polling",
            display_name="Telegram Main",
            account_id="",
            owner_user_id="user-1",
            auth_type="token",
            credentials_ref=credentials_ref,
            runtime_config={"max_message_chars": 4096},
        )
        connector = TelegramConnector(
            connection_store=connection_store,
            credential_store=credential_store,
            client_factory=lambda token: FakeTelegramClient(),
        )

        result = await connector.validate(connection.connection_id)
        updated = connection_store.get(connection.connection_id)

        assert result.ok is True
        assert result.status == "connected"
        assert result.account_id == "telegram:12345"
        assert updated.account_id == "telegram:12345"
        assert updated.display_name == "Beaver (@beaver_bot)"
        assert updated.capabilities == ["receive_text", "send_text", "receive_media", "groups"]

    asyncio.run(run())


def test_telegram_connector_materializes_runtime_spec(tmp_path) -> None:
    async def run() -> None:
        connection_store = ChannelConnectionStore(tmp_path / "connections.json")
        credential_store = CredentialStore(tmp_path / "credentials.json")
        credentials_ref = credential_store.put(kind="telegram", values={"botToken": "token-1"})
        connection = connection_store.create(
            kind="telegram",
            mode="polling",
            display_name="Telegram Main",
            account_id="telegram:12345",
            owner_user_id=None,
            auth_type="token",
            credentials_ref=credentials_ref,
            runtime_config={"max_message_chars": 4096, "require_mention_in_groups": True},
        )
        connection_store.update_status(connection.connection_id, status="connected", last_error=None)
        connector = TelegramConnector(
            connection_store=connection_store,
            credential_store=credential_store,
            client_factory=lambda token: FakeTelegramClient(),
        )

        spec = await connector.materialize_runtime(connection.connection_id)

        assert spec.channel_id == connection.channel_id
        assert spec.kind == "telegram"
        assert spec.mode == "polling"
        assert spec.account_id == "telegram:12345"
        assert spec.config["max_message_chars"] == 4096
        assert spec.config["require_mention_in_groups"] is True
        assert spec.secrets_ref == credentials_ref

    asyncio.run(run())


def test_telegram_connector_validation_failure_sets_error_status(tmp_path) -> None:
    async def run() -> None:
        connection_store = ChannelConnectionStore(tmp_path / "connections.json")
        credential_store = CredentialStore(tmp_path / "credentials.json")
        credentials_ref = credential_store.put(kind="telegram", values={"botToken": "bad-token"})
        connection = connection_store.create(
            kind="telegram",
            mode="polling",
            display_name="Telegram Main",
            account_id="",
            owner_user_id=None,
            auth_type="token",
            credentials_ref=credentials_ref,
        )
        connector = TelegramConnector(
            connection_store=connection_store,
            credential_store=credential_store,
            client_factory=lambda token: BrokenTelegramClient(),
        )

        result = await connector.validate(connection.connection_id)

        assert result.ok is False
        assert result.status == "error"
        assert "invalid token" in (result.error or "")

    asyncio.run(run())


def test_telegram_connector_revoke_leaves_store_status_to_registry(tmp_path) -> None:
    async def run() -> None:
        connection_store = ChannelConnectionStore(tmp_path / "connections.json")
        credential_store = CredentialStore(tmp_path / "credentials.json")
        connection = connection_store.create(
            kind="telegram",
            mode="polling",
            display_name="Telegram Main",
            account_id="telegram:12345",
            owner_user_id=None,
            auth_type="token",
        )
        connection_store.update_status(connection.connection_id, status="connected", last_error=None)
        connector = TelegramConnector(
            connection_store=connection_store,
            credential_store=credential_store,
            client_factory=lambda token: FakeTelegramClient(),
        )

        await connector.revoke(connection.connection_id)

        assert connection_store.get(connection.connection_id).status == "connected"

    asyncio.run(run())
```

- [ ] **Step 2: Run tests to verify they fail**

Run:

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

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

- [ ] **Step 3: Verify Telegram dependency**

Run:

```bash
cd app-instance/backend
rg -n "python-telegram-bot" pyproject.toml uv.lock | sed -n '1,20p'
```

Expected output includes `python-telegram-bot>=22.0,<23.0`. The default client factory may use `from telegram import Bot`, and `Bot.get_me()` is awaitable in this dependency line.

- [ ] **Step 4: Implement TelegramConnector**

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

```python
"""Telegram channel connector."""

from __future__ import annotations

from collections.abc import Callable
from typing import Any

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


class TelegramConnector:
    kind = "telegram"

    def __init__(
        self,
        *,
        connection_store: ChannelConnectionStore,
        credential_store: CredentialStore,
        client_factory: Callable[[str], Any] | None = None,
    ) -> None:
        self.connection_store = connection_store
        self.credential_store = credential_store
        self.client_factory = client_factory or _default_client_factory

    async def validate(self, connection_id: str) -> ValidationResult:
        connection = self.connection_store.get(connection_id)
        token = self._bot_token(connection.credentials_ref)
        try:
            client = self.client_factory(token)
            raw = await client.get_me()
            bot_id = _value(raw, "id")
            username = _value(raw, "username")
            first_name = _value(raw, "first_name") or "Telegram Bot"
            account_id = f"telegram:{bot_id}" if bot_id else connection.account_id
            display_name = f"{first_name} (@{username})" if username else first_name
            connection.account_id = account_id
            connection.display_name = display_name
            connection.capabilities = ["receive_text", "send_text", "receive_media", "groups"]
            self.connection_store.update(connection)
            return ValidationResult(
                ok=True,
                status="connected",
                account_id=account_id,
                display_name=display_name,
                metadata={"username": username} if username else {},
            )
        except Exception as exc:
            return ValidationResult(ok=False, status="error", error=str(exc))

    async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec:
        connection = self.connection_store.get(connection_id)
        if connection.status not in {"connected", "running"}:
            raise ValueError(f"Connection is not connected: {connection.connection_id}")
        return ChannelRuntimeSpec(
            channel_id=connection.channel_id,
            kind=connection.kind,
            mode=connection.mode,
            account_id=connection.account_id,
            display_name=connection.display_name,
            config=dict(connection.runtime_config),
            secrets_ref=connection.credentials_ref,
        )

    async def revoke(self, connection_id: str) -> None:
        # Telegram bot tokens do not have a Beaver-managed platform revoke action.
        # The registry owns local connection state transitions.
        return None

    def _bot_token(self, credentials_ref: str | None) -> str:
        if not credentials_ref:
            raise ValueError("Telegram credentials are missing")
        token = self.credential_store.get(credentials_ref).get("botToken")
        if not token:
            raise ValueError("botToken is required")
        return token


def _value(raw: Any, key: str) -> str:
    if isinstance(raw, dict):
        value = raw.get(key)
    else:
        value = getattr(raw, key, None)
    return str(value).strip() if value is not None else ""


def _default_client_factory(token: str) -> Any:
    try:
        from telegram import Bot
    except ImportError as exc:  # pragma: no cover - optional live dependency
        raise RuntimeError("Install beaver-backend[telegram] to validate Telegram connections") from exc
    return Bot(token=token)
```

- [ ] **Step 5: Export TelegramConnector**

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

```python
"""Channel connection setup layer."""

from .connectors import ChannelConnector, ChannelConnectorRegistry
from .models import ChannelConnection, ChannelRuntimeSpec, PairingSession, ValidationResult
from .store import ChannelConnectionStore, CredentialStore, PairingTokenStore
from .telegram import TelegramConnector

__all__ = [
    "ChannelConnector",
    "ChannelConnectorRegistry",
    "ChannelConnection",
    "ChannelRuntimeSpec",
    "PairingSession",
    "ValidationResult",
    "ChannelConnectionStore",
    "CredentialStore",
    "PairingTokenStore",
    "TelegramConnector",
]
```

- [ ] **Step 6: Run Telegram connector tests**

Run:

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

Expected: `4 passed`.

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

```bash
git add app-instance/backend/beaver/interfaces/channels/connections app-instance/backend/tests/unit/test_telegram_channel_connector.py
git commit -m "feat: add telegram channel connector"
```

---

### Task 4: Runtime Materialization From Connections

**Files:**
- Modify: `app-instance/backend/beaver/interfaces/web/app.py`
- Test: `app-instance/backend/tests/unit/test_channel_connector_registry.py`

- [ ] **Step 1: Verify ChannelConfig fields**

Run:

```bash
cd app-instance/backend
uv run python - <<'PY'
from dataclasses import fields
from beaver.foundation.config.schema import ChannelConfig
print([field.name for field in fields(ChannelConfig)])
PY
```

Expected output includes `enabled`, `kind`, `mode`, `account_id`, `display_name`, `config`, and `secrets`.

- [ ] **Step 2: Extend registry tests for ChannelConfig materialization**

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

```python
from beaver.foundation.config.schema import ChannelConfig


def test_connector_registry_materializes_channel_configs_with_credentials(tmp_path) -> None:
    async def run() -> None:
        connection_store = ChannelConnectionStore(tmp_path / "connections.json")
        credential_store = CredentialStore(tmp_path / "credentials.json")
        credentials_ref = credential_store.put(kind="telegram", values={"botToken": "token-1"})
        connection = connection_store.create(
            kind="fake",
            mode="webhook",
            display_name="Connected",
            account_id="connected",
            owner_user_id=None,
            auth_type="token",
            credentials_ref=credentials_ref,
        )
        connection_store.update_status(connection.connection_id, status="connected", last_error=None)

        class CredentialAwareConnector(FakeConnector):
            async def materialize_runtime(self, connection_id: str) -> ChannelRuntimeSpec:
                stored = connection_store.get(connection_id)
                return ChannelRuntimeSpec(
                    channel_id="fake-channel",
                    kind="fake",
                    mode="webhook",
                    account_id="fake-account",
                    display_name="Fake",
                    config={"enabled": True},
                    secrets_ref=stored.credentials_ref,
                )

        registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store)
        registry.register(CredentialAwareConnector())

        configs = await registry.materialize_channel_configs()

        assert isinstance(configs["fake-channel"], ChannelConfig)
        assert configs["fake-channel"].enabled is True
        assert configs["fake-channel"].secrets == {"botToken": "token-1"}

    asyncio.run(run())
```

- [ ] **Step 3: Run registry tests to verify failure**

Run:

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

Expected: fail with `AttributeError: 'ChannelConnectorRegistry' object has no attribute 'materialize_channel_configs'`.

- [ ] **Step 4: Implement channel config materialization**

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

```python
from beaver.foundation.config.schema import ChannelConfig
```

Add this method to `ChannelConnectorRegistry`:

```python
    async def materialize_channel_configs(self) -> dict[str, ChannelConfig]:
        channels: dict[str, ChannelConfig] = {}
        for spec in await self.materialize_connected_runtime_specs():
            secrets = self.credential_store.get(spec.secrets_ref) if spec.secrets_ref else {}
            channels[spec.channel_id] = ChannelConfig(
                enabled=True,
                kind=spec.kind,
                mode=spec.mode,
                account_id=spec.account_id,
                display_name=spec.display_name,
                config=dict(spec.config),
                secrets=secrets,
            )
        return channels
```

- [ ] **Step 5: Add app helpers for connection state paths and registry construction**

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

```python
from beaver.interfaces.channels.connections import (
    ChannelConnectionStore,
    ChannelConnectorRegistry,
    CredentialStore,
    TelegramConnector,
)
```

Add helper functions near `get_channel_runtime()`:

```python
def _connection_state_dir(workspace: Path) -> Path:
    return Path(workspace) / "state" / "channel_connections"


def _build_channel_connector_registry(workspace: Path) -> ChannelConnectorRegistry:
    state_dir = _connection_state_dir(workspace)
    connection_store = ChannelConnectionStore(state_dir / "connections.json")
    credential_store = CredentialStore(state_dir / "credentials.json")
    registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store)
    registry.register(
        TelegramConnector(
            connection_store=connection_store,
            credential_store=credential_store,
        )
    )
    return registry
```

- [ ] **Step 6: Merge materialized connections into runtime startup**

Modify the lifespan block in `app-instance/backend/beaver/interfaces/web/app.py` where `ChannelRuntime` is created:

```python
        loaded = attached_service.create_loop().boot()
        app.state.channel_connection_workspace = loaded.workspace
        connector_registry = _build_channel_connector_registry(loaded.workspace)
        app.state.channel_connector_registry = connector_registry
        connection_channels = await connector_registry.materialize_channel_configs()
        runtime_channels = dict(loaded.config.channels)
        runtime_channels.update(connection_channels)
        channel_runtime = ChannelRuntime(
            service=attached_service,
            workspace=loaded.workspace,
            channels=runtime_channels,
        )
```

Keep `app.state.channel_connector_registry = connector_registry` before runtime startup so API handlers can reuse the same stores.

- [ ] **Step 7: Run registry tests**

Run:

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

Expected: all tests pass.

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

```bash
git add app-instance/backend/beaver/interfaces/channels/connections/connectors.py app-instance/backend/beaver/interfaces/web/app.py app-instance/backend/tests/unit/test_channel_connector_registry.py
git commit -m "feat: materialize channel connections into runtime config"
```

---

### Task 5: Connection Control 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_channel_connection_api.py`

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

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

```python
from __future__ import annotations

from fastapi.testclient import TestClient

from beaver.interfaces.web.app import create_app
from beaver.services.agent_service import AgentService


def test_channel_connection_api_creates_updates_lists_and_revokes(tmp_path) -> None:
    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)

    try:
        with TestClient(app) as client:
            created = client.post(
                "/api/channel-connections",
                json={
                    "kind": "telegram",
                    "mode": "polling",
                    "displayName": "Telegram Main",
                    "authType": "token",
                    "secrets": {"botToken": "token-1"},
                    "config": {"maxMessageChars": 4096, "requireMentionInGroups": True},
                },
            )
            assert created.status_code == 200
            body = created.json()
            connection_id = body["connection"]["connection_id"]
            assert body["connection"]["kind"] == "telegram"
            assert body["connection"]["status"] == "draft"
            assert "credentials_ref" not in body["connection"]
            assert body["connection"]["runtime_config"] == {
                "max_message_chars": 4096,
                "require_mention_in_groups": True,
            }
            assert body["credentials"] == {"botToken": "***"}

            patched = client.patch(
                f"/api/channel-connections/{connection_id}",
                json={
                    "displayName": "Telegram Ops",
                    "config": {"maxMessageChars": 2048},
                    "secrets": {"botToken": "token-2"},
                },
            )
            assert patched.status_code == 200
            assert patched.json()["connection"]["display_name"] == "Telegram Ops"
            assert patched.json()["connection"]["runtime_config"] == {"max_message_chars": 2048}
            assert patched.json()["credentials"] == {"botToken": "***"}

            listed = client.get("/api/channel-connections")
            assert listed.status_code == 200
            assert listed.json()[0]["connection_id"] == connection_id
            assert "credentials_ref" not in listed.json()[0]

            revoked = client.post(f"/api/channel-connections/{connection_id}/revoke")
            assert revoked.status_code == 200
            assert revoked.json()["connection"]["status"] == "revoked"
    finally:
        service.close()


def test_channel_connectors_api_lists_registered_connectors(tmp_path) -> None:
    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)

    try:
        with TestClient(app) as client:
            response = client.get("/api/channel-connectors")
    finally:
        service.close()

    assert response.status_code == 200
    assert response.json() == [{"kind": "telegram"}]
```

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

Run:

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

Expected: fail with `404 Not Found` for `/api/channel-connections`.

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

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

```python
class WebChannelConnectionCreateRequest(BaseModel):
    """Create a channel connection from the setup UI."""

    kind: str
    mode: str
    display_name: str | None = Field(default=None, alias="displayName")
    owner_user_id: str | None = Field(default=None, alias="ownerUserId")
    auth_type: str = Field(default="token", alias="authType")
    account_id: str | None = Field(default=None, alias="accountId")
    config: dict[str, Any] = Field(default_factory=dict)
    secrets: dict[str, str | None] = Field(default_factory=dict)


class WebChannelConnectionResponse(BaseModel):
    """Channel connection response with redacted credentials."""

    connection: dict[str, Any]
    credentials: dict[str, str] = Field(default_factory=dict)


class WebChannelConnectionUpdateRequest(BaseModel):
    """Update editable channel connection setup fields."""

    display_name: str | None = Field(default=None, alias="displayName")
    account_id: str | None = Field(default=None, alias="accountId")
    config: dict[str, Any] | None = None
    secrets: dict[str, str | None] | None = None


class WebChannelValidationResponse(BaseModel):
    """Connector validation response."""

    ok: bool
    status: str
    account_id: str | None = None
    display_name: str | None = None
    error: str | None = None
    metadata: dict[str, Any] = Field(default_factory=dict)
    connection: dict[str, Any]
```

- [ ] **Step 4: Export web schemas**

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

```python
    WebChannelConnectionCreateRequest,
    WebChannelConnectionResponse,
    WebChannelConnectionUpdateRequest,
    WebChannelValidationResponse,
```

- [ ] **Step 5: Add connector registry accessors to app.py**

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

```python
from beaver.interfaces.web.schemas import (
    WebChannelConnectionCreateRequest,
    WebChannelConnectionResponse,
    WebChannelConnectionUpdateRequest,
    WebChannelValidationResponse,
)
```

Add helper:

```python
def get_channel_connector_registry(request: Request) -> ChannelConnectorRegistry:
    registry = getattr(request.app.state, "channel_connector_registry", None)
    if not isinstance(registry, ChannelConnectorRegistry):
        workspace = getattr(request.app.state, "channel_connection_workspace", None)
        if workspace is None:
            raise RuntimeError("Channel connector registry unavailable before service boot")
        registry = _build_channel_connector_registry(workspace)
        request.app.state.channel_connector_registry = registry
    return registry


def _normalize_connection_config(config: dict[str, Any] | None) -> dict[str, Any]:
    if not isinstance(config, dict):
        return {}
    return {
        _camel_to_snake_text(str(key)): value
        for key, value in config.items()
        if str(key).strip()
    }


def _camel_to_snake_text(value: str) -> str:
    result: list[str] = []
    for char in value:
        if char.isupper() and result:
            result.append("_")
        result.append(char.lower())
    return "".join(result)


def _connection_response_view(connection: Any) -> dict[str, Any]:
    view = connection.to_dict()
    view.pop("credentials_ref", None)
    view.pop("connector_ref", None)
    view.pop("pairing_session_id", None)
    return view
```

- [ ] **Step 6: Add connection API routes**

Add routes near existing `/api/channels` routes in `app-instance/backend/beaver/interfaces/web/app.py`:

```python
    @app.get("/api/channel-connectors")
    async def list_channel_connectors(request: Request) -> list[dict[str, str]]:
        return get_channel_connector_registry(request).connectors()

    @app.get("/api/channel-connections")
    async def list_channel_connections(request: Request) -> list[dict[str, Any]]:
        registry = get_channel_connector_registry(request)
        return [_connection_response_view(connection) for connection in registry.connection_store.list()]

    @app.post("/api/channel-connections", response_model=WebChannelConnectionResponse)
    async def create_channel_connection(
        request: Request,
        payload: WebChannelConnectionCreateRequest,
    ) -> WebChannelConnectionResponse:
        registry = get_channel_connector_registry(request)
        kind = _clean_text(payload.kind)
        mode = _clean_text(payload.mode)
        if not kind:
            raise HTTPException(status_code=400, detail="Connection kind is required")
        if not mode:
            raise HTTPException(status_code=400, detail="Connection mode is required")
        secrets = {key: value for key, value in payload.secrets.items() if value}
        credentials_ref = registry.credential_store.put(kind=kind, values=secrets) if secrets else None
        connection = registry.connection_store.create(
            kind=kind,
            mode=mode,
            display_name=_clean_text(payload.display_name) or kind,
            account_id=_clean_text(payload.account_id) or "",
            owner_user_id=_clean_text(payload.owner_user_id) or None,
            auth_type=_clean_text(payload.auth_type) or "token",
            credentials_ref=credentials_ref,
            runtime_config=_normalize_connection_config(payload.config),
        )
        return WebChannelConnectionResponse(
            connection=_connection_response_view(connection),
            credentials=registry.credential_store.redacted(credentials_ref),
        )

    @app.patch("/api/channel-connections/{connection_id}", response_model=WebChannelConnectionResponse)
    async def update_channel_connection(
        connection_id: str,
        request: Request,
        payload: WebChannelConnectionUpdateRequest,
    ) -> WebChannelConnectionResponse:
        registry = get_channel_connector_registry(request)
        try:
            connection = registry.connection_store.get(connection_id)
        except KeyError:
            raise HTTPException(status_code=404, detail="Channel connection not found")
        if payload.display_name is not None:
            connection.display_name = _clean_text(payload.display_name) or connection.display_name
        if payload.account_id is not None:
            connection.account_id = _clean_text(payload.account_id) or connection.account_id
        if payload.config is not None:
            connection.runtime_config = _normalize_connection_config(payload.config)
        if payload.secrets:
            secrets = {key: value for key, value in payload.secrets.items() if value}
            if secrets:
                # TODO: add credential GC when connection updates credentials.
                connection.credentials_ref = registry.credential_store.put(kind=connection.kind, values=secrets)
        connection = registry.connection_store.update(connection)
        return WebChannelConnectionResponse(
            connection=_connection_response_view(connection),
            credentials=registry.credential_store.redacted(connection.credentials_ref),
        )

    @app.get("/api/channel-connections/{connection_id}", response_model=WebChannelConnectionResponse)
    async def get_channel_connection(connection_id: str, request: Request) -> WebChannelConnectionResponse:
        registry = get_channel_connector_registry(request)
        try:
            connection = registry.connection_store.get(connection_id)
        except KeyError:
            raise HTTPException(status_code=404, detail="Channel connection not found")
        return WebChannelConnectionResponse(
            connection=_connection_response_view(connection),
            credentials=registry.credential_store.redacted(connection.credentials_ref),
        )

    @app.post("/api/channel-connections/{connection_id}/validate", response_model=WebChannelValidationResponse)
    async def validate_channel_connection(connection_id: str, request: Request) -> WebChannelValidationResponse:
        registry = get_channel_connector_registry(request)
        try:
            result = await registry.validate(connection_id)
            connection = registry.connection_store.get(connection_id)
        except KeyError:
            raise HTTPException(status_code=404, detail="Channel connection not found")
        return WebChannelValidationResponse(
            ok=result.ok,
            status=result.status,
            account_id=result.account_id,
            display_name=result.display_name,
            error=result.error,
            metadata=result.metadata,
            connection=_connection_response_view(connection),
        )

    @app.post("/api/channel-connections/{connection_id}/revoke", response_model=WebChannelConnectionResponse)
    async def revoke_channel_connection(connection_id: str, request: Request) -> WebChannelConnectionResponse:
        registry = get_channel_connector_registry(request)
        try:
            await registry.revoke(connection_id)
            connection = registry.connection_store.get(connection_id)
        except KeyError:
            raise HTTPException(status_code=404, detail="Channel connection not found")
        return WebChannelConnectionResponse(connection=_connection_response_view(connection), credentials={})
```

- [ ] **Step 7: Run API tests**

Run:

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

Expected: `2 passed`.

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

Run:

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

Expected: all focused connector tests pass.

- [ ] **Step 9: 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_channel_connection_api.py
git commit -m "feat: add channel connection control api"
```

---

### Task 6: Final Verification And Spec Alignment

**Files:**
- Review: `docs/superpowers/specs/2026-06-02-channel-connectors-and-pairing-design.md`
- Review: `docs/superpowers/specs/2026-06-02-chat-platform-channel-adapters-design.md`
- Review: `docs/superpowers/specs/2026-06-01-terminal-websocket-channel-design.md`

- [ ] **Step 1: Run connector and existing channel tests**

Run:

```bash
cd app-instance/backend
uv run pytest \
  tests/unit/test_channel_connection_store.py \
  tests/unit/test_channel_connector_registry.py \
  tests/unit/test_telegram_channel_connector.py \
  tests/unit/test_channel_connection_api.py \
  tests/unit/test_channel_runtime.py \
  tests/unit/test_telegram_channel_adapter.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 secret values in connector events and responses**

Run:

```bash
cd app-instance/backend
rg -n "token-1|token-2|bad-token|secret-token" tests/unit beaver || true
```

Expected: test fixture strings only appear in test files. They must not appear in implementation files or generated event log code.

- [ ] **Step 4: Update connector and adapter spec wording if still contradictory**

If `docs/superpowers/specs/2026-06-02-channel-connectors-and-pairing-design.md` still says Weixin uses a local plugin installer, dynamically launched connector process, or `ChannelRuntime external adapter`, change only the Weixin/external-process wording to this architecture:

```markdown
Weixin personal-account support uses a docker-compose predeclared sidecar connector. Beaver calls the sidecar's existing HTTP API and must not dynamically create containers or require Docker socket access.
```

```markdown
For Weixin, the sidecar owns the platform protocol, QR login, inbound receive loop, outbound send, and login-state persistence. Beaver exposes it to the runtime through an `ExternalConnectorChannel`: inbound sidecar webhooks call Beaver's connector bridge endpoint, which submits normalized messages to `ChannelRuntime.accept_inbound()`, while outbound runtime messages call the sidecar `/send` API.
```

If `docs/superpowers/specs/2026-06-02-chat-platform-channel-adapters-design.md` still describes `WeixinAdapter` as the Beaver-owned protocol adapter, change only the Weixin adapter scope and access-control text:

```markdown
- Use internal adapters by default, but allow external connector processes where platform SDK or login state requires them.
```

```markdown
Pairing is owned by the connector layer. Platform adapters assume a materialized `ChannelConnection` and adapter-ready runtime config.
```

```markdown
For Weixin personal-account support, the runtime channel is an `ExternalConnectorChannel`, not a Beaver-owned `WeixinAdapter`. The docker-compose sidecar is the Weixin protocol adapter; Beaver only owns connector setup state, bridge API validation, message normalization boundaries, runtime dedupe, and outbound HTTP calls to the sidecar.
```

- [ ] **Step 5: Commit spec alignment if changed**

If Step 4 changed docs:

```bash
git add \
  docs/superpowers/specs/2026-06-02-channel-connectors-and-pairing-design.md \
  docs/superpowers/specs/2026-06-02-chat-platform-channel-adapters-design.md
git commit -m "docs: align channel specs with external connector channels"
```

If Step 4 made no change, do not create an empty commit.

- [ ] **Step 6: Summarize remaining rollout**

Record in the final implementation response that this first plan does not implement Terminal pairing, Feishu/Lark connector, Weixin docker-compose sidecar pairing/bridge, QQBot connector, frontend wizard, or hot adapter restart. Those are separate plans.

For Weixin specifically, record the agreed follow-up architecture:

```text
Weixin sidecar connector
  -> Beaver connector bridge endpoint
  -> ChannelRuntime.accept_inbound()
  -> MessageBus
  -> AgentService

AgentService
  -> MessageBus outbound
  -> ExternalConnectorChannel.send()
  -> Weixin sidecar connector /send
```

Do not describe the follow-up path as `sidecar -> WeixinAdapter -> ChannelRuntime`. The sidecar is the Weixin protocol adapter; Beaver's runtime object should be named `ExternalConnectorChannel` or an equivalently generic connector-bridge channel.
