채팅 브리지
채팅 브리지는 외부 채팅 제품(현재는 Slack)을 Control Center 워크스페이스로 들어가는 일급 경로로 만듭니다. 봇을 멘션하면 에이전트가 깨어나고, Slack 스레드에서 답장하면 대화가 이어지며, 슬래시 명령을 입력하면 티켓이 생성됩니다. 그 아래에서는 데스크톱 앱이 쓰는 것과 동일한 스페이스, 트랜스크립트, 티켓, 귀속이 그대로 동작합니다. 채팅은 워크스페이스 위의 _표면_이며, 그 사본이 아닙니다.
Slack은 실제로 제공되는 유일한 제공업체입니다. 제품 어디에도 Discord, Telegram, Teams, Matrix 구현은 없습니다. 이 기능은 의도적으로 제공업체를 모르는 상태로 작성되었습니다 — 두 번째 제품을 추가하는 일은 enum 값 하나, 어댑터 하나, 플러그인 하나뿐입니다 — 하지만 오늘날 “채팅”은 Slack을 뜻합니다.
이 기능을 다루는 표면은 두 가지이며, 서로 다른 사람의 몫입니다. 워크스페이스를 Slack에 연결하고 봇을 사용자 지정하는 것은 설정 → 워크스페이스 → 일반 아래의 관리자 작업입니다. 자신의 채팅 계정을 Control Center 신원에 연결하는 것은 설정 → 워크스페이스 → 프로필 및 신원 아래의 멤버 작업입니다.
세 가지 결정이 기능 전체의 형태를 정합니다: 연결이 어느 방향으로 흐르는가, 외부에서 도착한 메시지를 무엇이 승인하는가, 제품별 지식이 어디에 살도록 허용되는가.
설계상 아웃바운드
섹션 제목: “설계상 아웃바운드”브리지는 제공업체의 수신 연결을 기다리지 않습니다. Slack에는 Socket Mode로 접근합니다: 서버가 Slack에 소켓 URL을 요청해 직접 연결하고, 그 연결을 통해 멘션, DM, 슬래시 명령을 받습니다.
그 결과가 바로 핵심입니다. NAT 뒤의 노트북, 도메인 없는 홈 서버, 네트워크를 옮겨 다니는 머신 — 모두 인바운드 포트도, 터널도, TLS 인증서도, 주소가 바뀔 때 교체할 공개 URL도 없이 브리지를 실행할 수 있습니다. 웹훅 엔드포인트가 없으므로 보호할 웹훅 엔드포인트도 없습니다. Control Center가 앱 정의를 구성할 때 Socket Mode를 강제하고 요청 URL을 모두 제거합니다. 둘 다에 맞춰 설정된 앱은 반만 동작하는 앱이기 때문입니다.
대가는 연결을 살려 두는 일이며, 이는 서버의 몫입니다. 재연결은 지터를 섞은 지수 백오프를 쓰되 30초로 상한을 두고, 제공업체가 시작한 소켓 교체는 그 백오프를 키우지 않고 다시 연결하며, _거부된 자격 증명_은 종결입니다 — 폐기된 토큰을 영원히 재시도하면 속도 제한만 얻을 뿐이므로, 브리지는 멈춰서 새 토큰을 기다리고 설정에는 제공업체가 보낸 오류를 그대로 보고합니다.
채팅 앱이 없는 워크스페이스는 비용이 전혀 들지 않습니다: 소켓도, 타이머도, 트래픽도 없습니다. 그리고 전체 기능은 서버의 준비 완료 배너 _이후_에 시작합니다. 응답이 느린 채팅 앱이 부팅을 지연시켜서는 안 되기 때문입니다.
승인하는 것은 멤버십이지 토큰 소유가 아닙니다
섹션 제목: “승인하는 것은 멤버십이지 토큰 소유가 아닙니다”앱을 설치한 사람과 메시지를 보내는 사람은 다릅니다. 그래서 모든 인바운드 이벤트는 무슨 일이 벌어지기 전에 Principal로 해석되며, 판단이 서지 않으면 설명과 함께 거부하는 쪽으로 실패합니다:
- 연결되지 않은 채팅 계정에는 자신이 글을 쓴 Slack 스레드에서 연결 방법을 알려 줍니다.
- 연결된 계정이지만 그 사용자가 이 워크스페이스의 멤버가 아닌 경우에는 관리자에게 초대를 요청하라고 알려 줍니다.
- 역할이 읽기 전용인 멤버(
viewer,guest)에게는 자신의 역할로는 작업을 시작할 수 없다고 알려 줍니다.
이 중 어느 경우도 진행되지 않습니다. 채팅 메시지는 절대 그것을 보내지 않은 사람에게 귀속되지 않습니다 — 이 규칙이 감사 추적과 Principal별 속도 제한, 티켓 보고자를 정직하게 유지합니다. 이 규칙이 기대는 멤버십 모델은 멀티플레이어 — 신원, 멤버십과 프레즌스를 참고하세요.
연결은 신원 해석이며 접근 권한 부여가 아닙니다. 멤버십은 이벤트마다 다시 확인하므로, 그사이 제거된 사람의 연결은 예전 권한이 아니라 거부로 귀결됩니다. 연결을 만드는 경로는 두 가지입니다: 멤버가 채팅에서 입력하는 단명 일회용 코드, 또는 제공업체의 검증된 이메일에 대한 자동 매칭입니다 — 이메일 경로도 매칭된 사용자가 이미 멤버일 것을 요구하므로, 주소만 우연히 서버에 존재하는 낯선 사람은 아무것에도 연결되지 않습니다.
코드는 의도적으로 메모리에만 보관하며 수명은 15분입니다. 코드를 영속화하면 사라질 운명인 것에 대해 자격 증명 모양의 영속 행을 만드는 셈이고, 서버 재시작이 미사용 코드를 모두 무효화하는 것은 회귀가 아니라 올바른 동작입니다 — 멤버는 “내 Slack 계정 연결”을 다시 누르면 됩니다.
거부는 메시지가 연 Slack 스레드 안에서 실제 답장으로 답합니다. 스레드 답장은 답하는 메시지에 배지를 달고, 그것이 필요한 바로 그 사람에게 도달하며, “연결되어 있지 않습니다”라는 말이 채널 자체에는 남지 않게 합니다. 메시지 게시는 모든 제공업체가 가진 유일한 공통 능력이기도 하므로, 설명이 앱에 없을 수도 있는 기능에 의존하는 일은 없습니다.
출처와 에코 문제
섹션 제목: “출처와 에코 문제”양방향으로 미러링하는 브리지는 두 방향을 구분해 주는 무언가가 없으면 루프에
빠집니다. 브리지가 들여오는 모든 메시지에는 metadata['chat']이 찍힙니다 —
제공업체, 팀, 대화, 스레드, 메시지, 발신자입니다. 아웃바운드 미러는 이
표식이 붙은 메시지는 모두 건너뜁니다.
이 규칙 하나가 양방향 미러링을 안전하게 만듭니다: Control Center에서 사람이 입력한 메시지는 연결된 스레드로 중계되고(작성자의 이름이 접두어로 붙습니다), 채팅에서 들어온 메시지는 들어온 지점에서 멈춥니다.
바깥으로 건너가는 것은 의도적으로 좁습니다. 에이전트의 답변은 평범한
채팅 텍스트로 중계됩니다. 짧은 생각은 카드의 Thinking… 행에 나타날 수
있습니다. 도구 출력은 diff의 벽이고 Control Center에 남으며, 잘린 답변이
가리키는 곳도 바로 그곳입니다.
카드는 작업을 보고하고, 텍스트가 여전히 메시지입니다
섹션 제목: “카드는 작업을 보고하고, 텍스트가 여전히 메시지입니다”제공업체가 카드를 렌더링하는 곳에서는 턴이 작업 카드 하나를 지니고, 턴이 도는 동안 제자리에서 편집되며, 설명하는 답변 위에 놓입니다.
카드는 뭉개 놓은 문단이 아니라 하나로 묶인 플랜입니다. Slack은 같은
작업에서 details와 output을 이어 붙이므로, 행의 details는 한 번만
전송되고(Thinking…의 생각) output은 절대 스트리밍되지 않습니다. 준비
단계는 제목이 교체되는 행 하나입니다(Working on it…, Cloning …). 추론은
Thinking… 행을 추가합니다. 각 도구는 자신만의 행을 갖습니다
(Read lib/main.dart). 답변은 카드 아래의 평범한 텍스트입니다 — Slack의
플랜 뷰는 답변을 작업에 섞여 들어온 청크가 아니라 나중에 덧붙는
마크다운에서 읽습니다. 그 외에는 아무것도 없습니다: 도구 출력은 없고, 짧은
허용 목록에 있는 도구 인자만 실립니다. 알 수 없는 도구의 인자는 파일 본문
전체일 수 있고, 행은 한 줄이기 때문입니다.
카드는 턴 _이전_의 대기까지 다룹니다. 첫 멘션이 대화를 만들고, 대화를
만드는 과정에서 에이전트가 작업할 모든 리포지토리를 클론합니다 — 실제
리포지토리라면 몇 분이 걸리는데, 그동안 에이전트는 아무것도 요청받지
않았고 스레드에서는 봇이 요청을 무시한 것처럼 보입니다. 그래서 카드는
멘션이 접수되는 순간 열리고(Working on it…), 이어서 그 준비 행이
데스크톱이 보여 주는 것과 같은 문구로 갱신됩니다 — Cloning acme/widgets…,
Setting up Reviewer…, Starting the agent… — 그다음 에이전트가 추론을
시작하는 즉시 Thinking… 행이, 그다음에는 도구마다 행이 하나씩 붙습니다.
카드 하나, 메시지 하나로, “잘 들었습니다”부터 그 아래 평범한 텍스트로
이어지는 답변까지 이어집니다. 준비 단계는 채팅에서 누군가 그것을 기다리는
곳에서만 보고됩니다: 앱에서 준비 중인 대화나 서버 부팅 시 재개된 고립된
대화는 카드 없이 프로비저닝됩니다.
카드는 채팅이 독자에게 줄 수 없는 단 하나를 담습니다: 답변 뒤에 있는
대화로 가는 View in Control Center 링크입니다. 채팅 제품은 링크에
http(s)만 허용하고 그 외에는 아무것도 받지 않으므로, 버튼은 이 서버가
제공하는 작은 페이지를 가리키고, 그 페이지가 브라우저를 데스크톱 앱의
control-center:// 링크로 넘깁니다. 그 페이지는 아무것도 읽지 않고
아무것도 해석하지 않으며 — URL에 있는 id는 딥 링크로 반향될 뿐 다른
어디에도 쓰이지 않습니다 — 접근 가능한 공개 URL이 없는 서버는 깨진 카드
대신 버튼이 없는 카드를 만들 뿐입니다.
카드도 여느 능력과 다를 바 없습니다. 카드가 없는 제공업체는 이전과 똑같이
텍스트만으로 동작하고, 호출 시점에 카드를 거부하는 제공업체는 스트림을
유지합니다: 답변은 언제나 그것을 설명하는 카드보다 우선합니다. 스트리밍이
완전히 꺼져 있으면 완성된 카드는 게시된 단일 메시지에 실려 갑니다. 생성된
티켓도 같은 대우를 받습니다 — /cc ticket 확인은 방금 만들어진 티켓으로
가는 링크가 존재 이유인 완성된 카드입니다.
능력은 협상하고, 그다음 기억합니다
섹션 제목: “능력은 협상하고, 그다음 기억합니다”채팅 제품마다 렌더링할 수 있는 것이 다르므로, 브리지는 가장 풍부한 표면을 기준으로 작성되고 — 스레드 안에서 실시간으로 자라나는 답변, 일시적인 “is thinking…” 상태, 제목이 있는 스레드, 슬래시 명령에 대한 일회성 답변 — 능력을 하나씩 낮춰 갑니다. 이 중 아무것도 없는 제공업체도 여전히 동작하며, 답변이 그냥 메시지 하나로 도착할 뿐입니다.
능력을 알린다고 해서 그것을 약속하는 것은 아닙니다. Slack의 스트리밍 답변은 유료 플랜 기능을 필요로 하며, Slack은 호출 시점에만 거부합니다. 영구로 표시된 거부는 연결이 살아 있는 내내 기억됩니다: 브리지는 요청을 그만두고 그때부터 완성된 답변을 통째로 게시하며, 설정에 그 사실이 분명히 적힙니다. “답변이 더 이상 스트리밍되지 않는다”는 로그를 읽지 않고도 답이 나와야 하기 때문입니다. Slack이 작업 카드를 거부하면 같은 방식으로 버려집니다.
모든 한도는 릴레이가 아니라 제공업체에 속합니다: 메시지 상한, 스트림 청크 상한, 스트림에 스레드 앵커가 필요한지 여부는 모두 어댑터가 선언하며, 긴 답변은 단어 중간이 아니라 문단, 줄, 단어 경계에서 나뉩니다.
브리지 하나, 제품당 어댑터 하나
섹션 제목: “브리지 하나, 제품당 어댑터 하나”제품의 형태를 띤 모든 것은 포트 뒤에 있습니다. 일반 코어는 마크다운, 채팅
대화, 채팅 스레드, 멤버를 알 뿐입니다. Slack 엔벨로프, mrkdwn, 게이트웨이
인텐트는 전혀 모릅니다. 세 가지 조각이 이를 가능하게 합니다:
- 어댑터는 제품 하나를 정규화합니다: 전송 방식, API 호출, 텍스트 코덱,
엔벨로프의 특이 사항입니다. Slack의 사례는 눈여겨볼 만합니다 — 채널
멘션은 두 번 도착하고(
app_mention으로, 동시에message로), 대부분의 메시지 하위 유형은 새 요청이 아니라 장식입니다. 둘 다 어댑터에서 해결되므로 코어는 Slack이 이중 전달한다는 사실을 결코 알지 못합니다. - 디스크립터는 제공업체의 공개 형태입니다: 어떤 자격 증명이 필요한지, 그 형식은 무엇인지, 어떤 URL이 사용자가 자격 증명을 찾는 데 도움이 되는지, 무엇을 할 수 있는지, 그리고 그 앱을 여기서 만들거나 편집할 수 있는지입니다. 설정 카드, 연결 대화상자, 인자 검증은 모두 여기서 _생성_됩니다 — 클라이언트는 제공업체와 필드 id를 키로 하는 맵을 되돌려 보냅니다.
- 플러그인은 서버 측 구성입니다: 자격 증명이 어떻게 검증된 연결이 되는지, 어댑터가 어떻게 만들어지는지, 제공업체 측 앱이 어떻게 관리되는지입니다.
따라서 제품을 추가하는 일은 enum 값 하나, 어댑터 하나, 플러그인 하나입니다. 새로운 RPC 연산도, 스키마 변경도, 클라이언트 변경도 없습니다 — 그리고 클라이언트는 정직하게 남습니다. 한 번도 들어본 적 없는 필드도 서버가 준 레이블로 그대로 렌더링되기 때문입니다.
두 가지 세부 사항이 추상화가 위로 새어 나가지 않게 막습니다. 제공업체에 API가 없는 앱 관리 단계(토큰 생성, 앱 설치, 권한 변경 후 재설치)는 자체 링크를 가진 이름 붙은 단계로 반환되므로, 설정 완료 화면은 어떤 제공업체인지 몰라도 그것들을 표시합니다. 그리고 브리지가 작성하는 모든 안내문은 제공업체와 앱이 실제로 사용하는 명령을 명시하므로, 명령 이름이 바뀌거나 두 번째 제공업체가 추가되어도 새 문자열 없이 올바르게 읽힙니다.
자격 증명이 사는 곳
섹션 제목: “자격 증명이 사는 곳”워크스페이스의 채팅 토큰은 그 워크스페이스의 자체 디렉터리, 데이터베이스
옆에 있습니다 — <dataDir>/<workspaceId>/chat_credentials/<provider>.json에
평범한 JSON으로 저장되며, 데이터베이스에도, 의도적으로 OS 키체인에도
들어가지 않습니다. 나중에 놓치기 쉬운 두 가지 결과가 있습니다:
워크스페이스를 삭제하면 디렉터리 연결이 해제되면서 토큰도 함께 사라지고,
워크스페이스를 내보내거나 백업하면 workspace.db가 복사되므로 다른
사람에게 넘긴 워크스페이스에는 살아 있는 봇 토큰이 들어 있지 않습니다. 이
파일은 서버 호스트의 파일 시스템 신뢰 경계를 물려받습니다 — 데이터
디렉터리에 읽기 접근 권한이 있는 것은 무엇이든 봇 토큰을 읽을 수 있고,
이는 데이터베이스가 놓여 있는 것과 같은 경계입니다.
제공업체당 파일 하나, 원자적으로 기록되고 소유자 전용입니다: 폐기된 앱은 파일 하나를 해제하는 것으로 삭제되고, 파일 하나가 손상되어도 모든 제공업체가 아니라 한 제공업체만 잃습니다. 자격 증명은 연결 연산을 통해 안으로만 들어오고 절대 다시 나가지 않습니다 — 상태 조회는 연결 메타데이터만으로 답합니다.
의도적으로 제공하지 않는 것
섹션 제목: “의도적으로 제공하지 않는 것”- 구조적 앱 설정. 전송 방식, 스코프, 이벤트 구독은 노출되지 않고 강제됩니다. 설정 화면에서 침묵하도록 구성할 수 있는 앱은 지원 티켓이지 기능이 아닙니다.
- 대화와 슬래시 명령. Slack 표면은 메시지, 스레드 답장, 슬래시 명령입니다.
- 두 번째 진실 공급원. 채팅은 작업이 저장되는 곳이 아닙니다. Slack 스레드는 스페이스이고, 답장은 트랜스크립트이며, 티켓은 티켓입니다 — 모두 워크스페이스 안에 있고 모든 클라이언트에서 접근할 수 있습니다.
관련 항목
섹션 제목: “관련 항목”- 멀티플레이어 — 신원, 멤버십과 프레즌스: 브리지가 해석해 거치는 Principal과 역할
- 워크스페이스와 격리: 링크와 자격 증명이 워크스페이스별인 이유
- 에이전트 모델: 멘션이 도착하면 무엇이 깨어나는지
- 티켓과 위임:
/cc ticket이 만드는 것 - 아키텍처: 브리지가 서버와 클라이언트 사이 어디에 있는지
- Slack을 워크스페이스에 브리지
- Slack 통합 설정
- Slack 계정 연결
- 채팅 봇 사용자 지정
- PR 대화: 코드 호스트에 대해 내린 동일한 세 가지 결정