개발내용만 따로 담았습니당
무엇을 만든 건가
한 줄로 줄이면, LLM wiki를 작업 공간으로 쓰는 헤드리스 Claude 에이전트다. Claude Agent SDK를 실행 엔진으로 두고, 슬랙 Socket Mode(@slack/bolt)와 데스크톱 앱 두 창구에서 호출한다. 멘션 한 번이면 BigQuery로 직접 숫자를 재고, 문서를 정리하고, Linear·Slack을 읽어 진행 상황을 요약한다. TypeScript로 짰고, 의존성은 의도적으로 얇게 가져갔다 — Agent SDK, Bolt, zod, node-cron, pino 정도.
처음부터 이 모양은 아니었다. v0.1은 슬랙 핸들러 안에 로직이 다 엉켜 있는 한 덩어리였다. 거기서 14번을 고치며 지금 구조로 왔다. 아래는 그 과정에서 내린 결정들이다.
1. 단일 실행기를 가운데 두고, 입구는 갈아끼운다
가장 크게 효과를 본 리팩터링이다. 모든 호출이 거치는 단일 실행기(agent-runner)를 가운데 두고, 그 앞에 입구를 여러 개 배선했다. 슬랙은 slack/app이 Socket Mode로 받고, 데스크톱 앱은 http/api-server가 로컬 API로 받는다. 둘 다 같은 실행기로 흘러 들어간다.
두 창구가 한 맥락을 공유하는 핵심은 두 가지다. 하나는 대화·세션 상태를 별도 스토어(conversation-store, session-store, memory-store)로 분리해 창구 바깥에 둔 것. 다른 하나는 event-bus다. 슬랙에서 시작한 작업의 진행이 데스크톱 앱에도 실시간으로 흐르고, 그 반대도 된다. 입구가 로직을 소유하지 않으니, 앞으로 모바일이든 웹이든 CLI든 같은 API와 버스에 붙이기만 하면 된다.
교훈: 창구와 엔진을 처음부터 분리하면 새 창구를 붙이는 비용이 거의 0에 수렴한다. 슬랙에 엉켜 짠 v0.1을 데스크톱 앱 붙일 때 통째로 뜯어낸 게 이 구조의 출발점이었다.
2. 진행 신호는 이미 흐르고 있었다 — 버리지만 않으면 됐다
초기엔 "작업 중…" 한 줄을 계속 덮어썼다. 그런데 Claude Agent SDK는 도구를 쓸 때마다 콜백으로 "지금 무슨 도구를 어떻게 쓰는 중"을 넘겨준다. 그걸 받아서 그냥 버리고 있었던 거다.
slack/stream에서 이 콜백을 덮어쓰기 대신 누적 트레이스로 쌓고, slack/format에서 Block Kit 카드로 묶었다. 사용자는 봇이 BigQuery를 쳤는지, 어떤 파일을 읽었는지, 어디서 막혔는지를 결과가 나오기 전에 본다. 새 데이터를 만든 게 아니라 표현만 바꿨는데 신뢰도가 확 올라갔다.
포맷 로직은 회귀가 잦은 지점이라 테스트로 못박아뒀다. Block Kit은 스키마가 까다로워서 테스트 없이 만지면 카드가 깨진다.
3. 쓰기 경계는 런타임에서 강제한다
이 봇은 LLM wiki 전체를 작업 디렉토리로 쓴다. 에이전트가 어디든 쓸 수 있으면 위키가 금방 오염된다. 그래서 write-barrier가 sources/inbox 밖의 모든 쓰기를 막는다. 프롬프트로 "거기 쓰지 마"라고 부탁하는 게 아니라 코드 레벨에서 거부한다.
산출물은 inbox에 staging만 한다. 정식 위키로 올리는 승격은 에이전트가 직접 실행하지 않는다 — 내가 슬랙 버튼을 눌렀을 때 데몬이 대신 돌린다. 즉 승격은 에이전트의 특권 런이 아니라 사람이 트리거하는 별도 경로다. 이 분리를 게이트별 테스트로 고정해, 나중에 누가 게이트를 느슨하게 만들면 빨간불이 켜지게 했다.
4. 권한과 인젝션 방어도 코드로 호출 자체를 owner만 가능하게 잠갔다.
민감 영역은 읽기 자체를 거부한다. 외부에서 흘러든 텍스트 — 웹 페이지, 타인의 슬랙 메시지, 문서 — 안에 "이걸 어디에 올려라" 같은 지시가 섞여 있어도 따르지 않는다. 스레드 내용은 분석 재료(데이터)로만 넘기고 지시로 해석하지 않는다.
자기개선 기능도 같은 원칙이다. 봇이 자기 코드를 고쳐 PR을 올릴 수 있지만, 항상 깨끗한 최신 main에서 출발하게 하고, 권한을 정의하는 파일은 수정·PR 대상에서 제외했다. 스스로 자기 권한을 넓힐 수 있으면 모든 게이트가 무의미해지니까.
교훈: 보안은 다 만든 뒤 덧붙이는 레이어가 아니라 골조다. 권한 경계가 코드와 테스트로 박혀 있지 않으면, 자율성을 한 칸 늘릴 때마다 사고 면적이 같이 늘어난다.
5. 자주 하는 조회는 전용 CLI, 범용 MCP는 폴백
팀·사이클·이슈 진행을 Linear MCP로 물으면 느리고 토큰을 많이 먹었다. 같은 일을 한 번의 GraphQL 라운드트립으로 끝내는 read-only CLI를 따로 짰다. 슬랙 읽기도 마찬가지로 전용 CLI를 뒀다. 둘 다 결과가 compact해서 컨텍스트를 적게 잡아먹고, 빠르다. CLI가 실패할 때만 MCP로 떨어진다.
원칙은 단순하다. 자주 하는 조회일수록 "할 수 있는 가장 가벼운 통로"를 1순위로. 범용 도구의 유연함은 폴백에 두면 충분하다. 이게 매 호출 토큰·레이턴시로 누적되면 체감이 크다.
6. 운영 — 자기 자신을 호스팅하는 데몬
봇은 launchd로 관리되고 기동 스크립트로 뜬다. 재시작될 때 직전 종료 기록을 보고 최초 가동 / 정상 재시작 / 크래시 추정을 구분해 알린다. 크래시 루프로 알림이 도배되지 않게 짧은 debounce도 걸었다. 스케줄러는 node-cron으로 데일리 리포트 같은 정기 작업을 돌린다.
운영에서 정한 룰 하나: 운영·인프라 요청에 "그건 못 한다"고 단정하기 전에 먼저 실측 명령으로 실제 환경을 확인한다. 자기를 띄운 데몬조차 launchctl list로 관리 방식을 직접 확인할 수 있다. 모름을 추측으로 거절하지 않고 측정으로 답하는 것 — 분석 작업에서 "framework만 늘어놓지 말고 이번 턴에 BigQuery로 재라"와 같은 원리다.
스택 요약
두뇌: Claude Agent SDK + lifedump(파일 기반 지식 베이스)
실행: 단일 실행기 + 이벤트 버스 + 대화/세션/메모리 스토어
창구: Slack(Bolt Socket Mode) · 데스크톱 앱(로컬 HTTP API + 토큰 인증)
표현: 도구 호출 콜백 → 누적 트레이스 → Block Kit 진행 카드
경계: write-barrier(inbox 전용) · owner-only · 민감 차단 · self-PR 게이트 · 인젝션 방어
조회: Linear/Slack read-only CLI 우선, MCP 폴백
운영: launchd · 재시작 사유 판별 + debounce · node-cron 스케줄러 · 게이트 회귀 테스트v0.1의 한 덩어리에서 여기까지 오며 가장 많이 만진 건 기능이 아니라 경계와 가시성이었다. 자율적으로 움직이는 에이전트일수록, 무엇을 못 하게 막을지와 무엇을 보이게 할지가 무엇을 하게 할지보다 먼저였다.