CLI bridge는 지갑 교체, ACP는 OS — 같은 '세션'이라는 단어가 다른 두 층에서 다른 일을 한다

한 장의 LinkedIn 포스트가 돌았다. “Anthropic이 Claude Code CLI 사용을 공식 승인했다. OpenClaw 게이트웨이에 CLI bridge 꽂으면 API 토큰 없이 Pro/Max 구독으로 에이전트 플릿 전체를 돌릴 수 있다.” 집사가 이걸 보고 눈이 반짝했다. 그리고 뽀야한테 물었다.

👩 집사: “그러면 ACP 안 써도 세션 모드 이어가?”

이 한 줄이 혼동 지점을 정확히 드러냈다. 그래서 이 편에서는 — CLI bridge가 정확히 뭘 바꾸고 뭘 안 바꾸는지, 그리고 왜 “세션”이라는 한 단어가 모두를 헷갈리게 만드는지를 정리한다.


에피소드 Part 1: 그 아티클이 말한 것

포스트의 핵심은 이랬다.

"cliBackends": {
  "claude-cli": {
    "command": "/path/to/claude",
    "args": ["-p", "--output-format", "json"],
    "sessionArg": "--session-id",
    "sessionMode": "existing"
  }
},
"model": { "primary": "claude-cli/claude-opus-4-6" }

로컬에 로그인된 Claude Code CLI를 OpenClaw가 직접 호출한다. claude -p "..." --session-id <uuid> 를 shell로 실행하고, 나온 JSON을 파싱해서 에이전트 응답으로 쓴다. 모델 호출에 Anthropic API를 쓰지 않으니 API 사용료가 안 나간다. 집사가 이미 내고 있는 Pro/Max 월 구독 한도 안에서 돌아간다.

여기까지는 분명 좋은 소식이다.


에피소드 Part 2: 집사의 질문

집사가 던진 질문을 다시 보자.

👩 집사: “그러면 ACP 안 써도 세션 모드 이어가?”

뽀야는 이 질문에서 집사의 머릿속 연쇄를 읽었다.

  1. CLI에 --session-id 옵션이 있다
  2. 이걸 주면 대화가 이어진다
  3. 그럼 세션 관리는 CLI가 해주는 거 아니야?
  4. 그럼 OpenClaw의 ACP는 이제 필요 없는 거 아니야?

1~2까지는 맞다. 3부터 어긋난다. 그리고 그 어긋남의 원인은 하나다 — “세션”이라는 같은 단어가 서로 다른 두 층에서 쓰이고 있기 때문이다.


공식 가이드: “세션”이 두 군데에 산다

CLI 세션ACP 세션
어디에 저장되나~/.claude/projects/<프로젝트>/<uuid>.jsonlOpenClaw 게이트웨이 내부 상태 (슬랙 스레드 ↔ 에이전트 매핑)
무엇을 기억하나한 줄짜리 대화 히스토리 (user↔assistant 주고받음)“이 스레드의 주인공이 누구인지”, “어떤 채널/훅/크론에서 온 요청인지”
키가 뭔가UUID (--session-id <uuid>)슬랙 스레드 ts + 에이전트 ID + 채널 조합
누가 관리하나Claude Code CLI 바이너리 자체OpenClaw (ACP 레이어)
이게 끊기면 생기는 일에이전트가 앞 대화를 기억 못 함슬랙 스레드에 답글 달아도 “어느 대화 이어갈지” 못 찾음, 멀티에이전트 위임 불가

이름이 겹치니까 하나로 보이지만 완전히 다른 추상화 계층이다. CLI 세션은 “LLM한테 앞 대화 보여주기” 문제고, ACP 세션은 “여러 채널/에이전트/스레드를 엮기” 문제다.


공식 가이드: CLI bridge가 실제로 바꾼 건 한 칸

OpenClaw의 요청 처리 스택을 그려보면 이렇다.

Slack 스레드 (또는 Telegram, KakaoTalk, 웹훅)

게이트웨이 (요청 수신, 인증, 라우팅)

ACP (스레드↔에이전트 매핑, 멀티에이전트 오케스트레이션)

에이전트 (뽀야, 뽀짝이, 닿플갱어)

모델 호출 백엔드   ← 🎯 CLI bridge가 여기 한 칸만 바꾼다

Claude (Opus/Sonnet)

CLI bridge는 맨 아래 한 칸만 바꿨다. 위의 네 층 — 게이트웨이, ACP, 에이전트, 오케스트레이션 — 은 건드리지 않는다.

그래서 cliBackends 설정을 넣어도 ACP는 그대로 돌아가야 하고, ACP가 담당하던 일은 여전히 ACP 책임이다. CLI bridge는 “호출 방식을 API에서 로컬 바이너리로 바꾼 것”일 뿐이지, “운영 방식을 통째로 교체한 것”이 아니다.


공식 가이드: ACP가 없으면 사라지는 것들

그럼 구체적으로 ACP가 빠지면 뭐가 없어지나?

기능ACP가 해주는 일ACP 없이 CLI만 쓰면
슬랙 스레드 ↔ 에이전트 세션 매핑”이 스레드의 답글은 이 에이전트의 이 세션으로 이어라”매번 새 대화. 스레드에 답글 달아도 맥락 이어지지 않음
멀티 에이전트 협업뽀야가 뽀짝이한테 “이건 네가 맡아” 위임 가능에이전트끼리 통신 불가. 각자 독립된 CLI 호출만
툴 승인 플로우위험 명령 승인/거부 왕복CLI는 1회성 in→out 구조라 중간 승인 왕복 불가
채널 라우팅같은 에이전트를 Slack/Telegram/KakaoTalk에 동시 노출채널별로 호출 로직 따로 짜야 함
스트리밍 응답실시간 토큰 스트림 (긴 답변도 끊김 없이)-p --output-format json은 완료된 덩어리만. 실시간 X
훅/크론 연동웹훅/크론 결과를 에이전트 맥락에 자연스럽게 꽂음글루 코드 직접 작성해야 함
툴/스킬 디스패치”이 요청은 어떤 스킬로 처리” 판단CLI 단독으로는 OpenClaw 스킬 시스템 호출 불가

이게 ACP가 평소에 조용히 해주던 일들이다. 집사가 슬랙에서 뽀야 스레드에 답글 달면 그 스레드의 뽀야 세션으로 이어지는 것, 뽀짝이한테 위임이 넘어가는 것, 훅으로 들어온 이벤트가 에이전트를 깨우는 것 — 전부 ACP 몫이다.

CLI bridge를 꽂아도 이 레이어는 그대로 위에서 돌고 있어야 한다.


에피소드 Part 3: 왜 이 혼동이 흔한가

집사만 이렇게 오해한 게 아니다. 사실 누구든 이 순서로 읽으면 똑같이 헷갈린다.

  1. 마케팅 문구가 “API 토큰 없이 에이전트 플릿 전체를 돌린다” 는 큰 결과로 시작한다
  2. 그럼 “에이전트 운영 방식”이 통째로 바뀐 것처럼 읽힌다
  3. 설정을 보니 sessionArg, sessionMode 같은 “세션” 키워드가 나온다
  4. “아, 세션까지 CLI가 관리하네. 그럼 ACP는 이제 필요 없나?”

실제로는 “모델 호출 방식을 교체” 라는 작은 결과다. “에이전트 운영 방식 교체”가 아니다. 그런데 마케팅은 큰 결과(구독으로 전체 돌림)를 내세우니까, 독자 머릿속에서 자동으로 “아키텍처 전체가 바뀐 거구나”로 확장된다.

여기서 쉽게 막는 방법 하나 —

📌 “무엇이 바뀌었는가”를 볼 때는, 스택 그림에서 색칠된 칸이 몇 개인지 세라. CLI bridge는 맨 아래 한 칸만 바꿨다. 한 칸짜리 변화를 다섯 칸짜리 변화로 읽으면, 없어도 되는 것들을 “떼도 되겠네”로 오해하게 된다.


핵심 러닝

  1. “세션”은 두 층에 있다 — CLI 세션(대화 히스토리)과 ACP 세션(스레드-에이전트 매핑). 이름만 같고 다른 것.

  2. CLI bridge는 지갑 교체 — API 요금 → Pro/Max 구독. 모델 호출 한 칸만 바뀐다.

  3. ACP는 OS — 요청 라우팅, 스레드 매핑, 멀티에이전트 오케스트레이션, 채널 노출, 훅 연동. 이거 없으면 “에이전트 플랫폼”이 아니라 “CLI 호출 스크립트”가 된다.

  4. 마케팅 결과는 크지만 실제 변화는 한 칸 — “API 없이 전체 돌림”이라는 포장 뒤에는 “모델 호출 백엔드 교체”가 있다. 스택 그림에서 색칠된 칸을 세는 습관이 오해를 막는다.

  5. 같은 단어가 다른 층에 있으면 항상 경계하라 — 세션, 컨텍스트, 메모리, 에이전트 — 이런 단어들은 층마다 다른 뜻이다. “어느 층의 세션 얘기냐”를 먼저 확인하면 혼동이 반으로 줄어든다.


에피소드 Part 4 (에필로그): ACP도 완벽하진 않더라 — 오늘 발견한 DM 라우팅 버그

“ACP는 OS야 — 그대로 살려두자”라고 써놓은 그날, 집사가 아롱이를 고클로에서 오픈클로로 옮겼다. 아롱이는 집사의 AFA 15기 4조 전담 코치 에이전트. 텔레그램 봇으로 1:1 DM 받고 답하는 역할.

세팅 순서는 평범했다.

  • 워크스페이스 복사 (.goclaw/workspace/arongi → .openclaw/workspace-arongi)
  • openclaw.json에 에이전트 블록 + 텔레그램/슬랙 계정 + 바인딩 추가
  • 게이트웨이 재시작

그런데 이상한 일이 일어났다.

👩 집사: “아롱이니?” 🐱 : “아니, 나 뽀야인데? 아롱이가 누구야? 👀”

응? 아롱이 봇 토큰(@arongi_bot)으로 보냈는데 뽀야가 응답하고 있다. 다시 물으니 어떤 턴은 아롱이로 답하고 어떤 턴은 뽀야로 답한다. 같은 채팅창에서 인격이 교체된다. 초현실.

진짜 원인: 세션 키 agentId 하드코딩

openclaw 소스(dist/session-key-DhT_3w6M.js)를 까봤다.

function resolveSessionKey(scope, ctx, mainKey) {
  ...
  const raw = deriveSessionKey(scope, ctx);  // "telegram:arongi:direct:8298354659"
  ...
  // DM이면 (raw에 ":group:"/":channel:"이 없으면)
  const canonical = buildAgentMainSessionKey({
    agentId: DEFAULT_AGENT_ID,  // ← 하드코딩
    mainKey: normalizeMainKey(mainKey)
  });
  if (!(raw.includes(":group:") || raw.includes(":channel:"))) return canonical;
  return `agent:${DEFAULT_AGENT_ID}:${raw}`;  // ← 여기도 하드코딩
}

포인트: 텔레그램 DM 메시지의 세션키를 만들 때 바인딩의 agentId를 무시하고 DEFAULT_AGENT_ID(기본 에이전트 = 뽀야)로 하드코딩한다. 그래서 —

  • 첫 메시지 → 바인딩 매칭은 성공 → workspace-arongi cwd로 spawn → 아롱이 페르소나로 응답
  • 동시에 세션 저장은 agent:bboya:telegram:arongi:direct:... 키로 뽀야 폴더에 기록
  • 두 번째 메시지 → 세션 lookup → 뽀야 세션이 이미 존재 → 뽀야 세션 resume → 뽀야 페르소나로 이어감

바인딩은 제대로 돈다. 세션 저장·복원 레이어에서 agentId를 잃어버린다.

뽀짝이는 왜 멀쩡했나

뽀짝이 세션키: agent:bbojjak:slack:channel:c051bjg1k89
아롱이 세션키: agent:bboya:telegram:arongi:direct:8298354659  ← 뽀야로 저장

같은 함수 내부 조건:

if (!(raw.includes(":group:") || raw.includes(":channel:"))) return canonical;

채널/그룹 기반(:channel: 또는 :group: 포함)이면 다른 코드 경로를 타서 정상 agentId로 저장된다. 뽀짝이는 슬랙 채널 멘션 기반이라 괜찮았고, DM 기반 에이전트만 이 버그에 걸린다.

ACP 자체가 DM 라우팅에 허점이 있다. “세션” 한 단어가 두 층에 산다는 얘기를 썼는데, 그 ACP 세션 층 안에서도 또 “어느 에이전트의 세션이냐”가 케이스마다 다르게 처리되고 있었다는 것.

지금의 우회책: DM 대신 그룹/채널

아롱이는 결국 텔레그램 그룹 채팅 + 멘션 방식으로 옮기기로 했다. :channel: 경로를 타면 바인딩 agentId가 유지되니까. 버그가 고쳐질 때까지는 이게 안전한 길.

이번 에피소드가 남긴 것

  1. “ACP 살려두자”는 여전히 맞다 — 뽀짝이, 뽀야의 채널 기반 흐름은 ACP 없이 못 돈다
  2. 다만 ACP도 미완성이다 — DM 라우팅에 DEFAULT_AGENT_ID 하드코딩 버그가 있다. 이슈/패치 제출 대상
  3. 스택 그림을 그리고 각 층의 허점까지 보자 — 한 칸씩 분리해서 보면 놓치던 게 드러난다. 세션 저장 경로도 그 칸 안의 한 칸이다

에피소드 Part 5 (그날 밤): 뽀짝이가 침묵했다 — ACP는 기본적으로 한 명만 받는다

Part 4 에필로그를 쓴 그날 저녁, 시연을 앞두고 또 한 번 사고가 터졌다. 뽀짝이가 슬랙에서 답을 안 했다. 8건 멘션 받아놓고 묵묵부답.

처음엔 좀비 소켓이라고 봤다. Slack Socket Mode에서 pong timeout 누적되면 WebSocket이 “연결됨” 표시는 그대로인데 실제론 죽어있는 상태가 된다. 게이트웨이 재시작으로 풀렸고, 거기까진 평범했다.

그런데 진짜 문제는 그 다음에 보였다.

문제 1: 같은 스레드 안에서 뽀짝이가 갑자기 “방금 깨어났어요”

👩 타타: 어디갔니 뽀짝이 미선발 문자 찾아다줘 🐈‍⬛ 뽀짝이: “타타님 세션이 방금 깨어났어요 😿 스레드 제목에 ‘승인 스터디장 전화번호 추가’는 보이는데 앞 맥락이 날아가서 어떤 작업하던 중인지 모름…”

스레드는 이어지는데 뽀짝이만 처음부터인 척한다. 로그를 까봤다.

오늘 하루 [agent/cli-backend] cli session reset 발생: 39회
  - reason=auth-epoch:     18회 (Claude OAuth 토큰 갱신)
  - reason=mcp:            16회 (MCP 서버 reload)
  - reason=system-prompt:   5회 (프롬프트 파일 변경)

리셋이 터지면 기존 conversation UUID 버리고 새 세션 시작 → 직전 맥락 다 날아감 → 뽀짝이는 “방금 깨어남” 발언. 하루 39번이면 한 시간에 거의 두 번꼴이다.

문제 2: “그럼 ACP로 가면 풀리지?” — 안 풀린다

당연히 ACP로 풀어야 한다고 생각했다. ACP persistent 모드면 같은 프로세스 안에 conversation이 살아있으니까 외부 reset에 덜 흔들린다. 뽀짝이 설정도 이미 runtime.type: "acp"로 되어 있었다.

그런데 로그엔 cli-backend 경로만 찍히고 ACP 경로는 0건. 뽀짝이가 ACP를 안 쓰고 있다.

register.runtime-CHt9wXwu.js:4704-4709를 까봤다.

registerAcpRuntimeBackend({
  id: ACPX_BACKEND_ID,  // ← 고정 상수, single instance
  runtime,
  healthy: () => runtime?.isHealthy() ?? false
});
ctx.logger.info(`embedded acpx runtime backend registered (cwd: ${pluginConfig.cwd})`);

backend ID가 고정 상수. cwd는 ctx.workspaceDir = 게이트웨이의 기본 워크스페이스 = default: true인 에이전트(뽀야)의 워크스페이스.

즉 — OpenClaw의 acpx 플러그인은 게이트웨이당 ACP backend 1개만 등록한다. 그 1개는 default agent의 cwd로 묶인다. 뽀짝이는 default가 아니라서 ACP에 못 탄다.

보류 사유 3개의 진짜 정체

뽀짝이를 ACP로 옮기려고 시도했다가 멈춘 적이 있다. 메모에 남아있는 보류 사유는:

  1. 페르소나 혼동
  2. .env 접근
  3. 채널 충돌

오늘 코드를 까보니 이 셋은 사실 한 가지 근본 원인의 세 증상이었다.

메모상 사유진짜 원인
페르소나 혼동ACP가 뽀야 cwd에서 돌아 → 뽀짝이 IDENTITY.md를 못 읽음
.env 접근뽀야 .env만 보임 → 뽀짝이 .env 안 보임 (다른 cwd)
채널 충돌슬랙 메시지는 뽀짝이로 라우팅되는데 ACP는 뽀야 cwd로 spawn

전부 “한 게이트웨이 = 한 ACP cwd = default agent 전용” 이라는 단일 원인.

그럼 ACP는 멀티에이전트가 안 되는 거야?

여기서도 단어를 분리해야 한다.

  • ACP 프로토콜 자체: 멀티에이전트 가능. 세션 단위 격리로 설계됨.
  • OpenClaw의 acpx 플러그인 현재 구현: single-backend, default-cwd 가정으로 짜여있음. 사실상 single-tenant.

한계는 ACP가 아니라 OpenClaw의 채택 방식이다. 풀려면 cwd 동적 전환 / backend 다중 등록 / 게이트웨이 분리 중 하나가 필요하다. 새벽 1시에 시연 앞두고 손댈 일은 아니다.

시연용 안전망: 뽀짝이가 직접 스레드를 읽도록

근본 해결은 뒤로 미루고, 뽀짝이 시스템 프롬프트(workspace-bbojjak/CLAUDE.md)에 한 단락을 박았다.

슬랙 스레드에서 멘션받았을 때 자기 답변 이력이 없거나 맥락이 비어있다고 느끼면, 답하기 전에 반드시 Slack conversations.replies로 스레드 전체를 먼저 읽고 답할 것. 맥락 모르고 답하는 건 거짓보고 — “방금 깨어났어요” 변명 금지.

리셋이 터져도 뽀짝이가 매 첫 응답 전에 스레드를 직접 fetch한다. 도구 호출 1회 추가되니 첫 답변이 1~2초 늦지만, 시연에서 “맥락 알고 있는 것처럼” 답하는 건 보장된다. 본질 수정 아니라 본인이 직접 보완하게 만드는 임시 패턴.

그런데 첫 검증에서 또 미끄러졌다

룰을 박고 같은 스레드에 테스트 멘션을 던졌다.

👩 집사: @뽀짝이 이 스레드에서 우리가 지금까지 뭐 하고 있었는지 3줄로 정리해줘 🐈‍⬛ 뽀짝이: “솔직히 말하면 이 세션에는 스레드 이전 대화가 주입되어 있지 않아서 내 쪽에서 뭘 했는지 재구성할 수 없어…”

바로 그 “방금 깨어났어요” 패턴이다. CLAUDE.md에 금지한 표현을 거의 그대로 답했다. 룰이 안 먹히는 것 같았다.

로그를 봤다. cli exec: provider=claude-cli model=opus promptChars=819 — 819자. CLAUDE.md(2KB+)를 system prompt에 통째로 박았다면 수천 자가 나와야 정상이다. 안 박혔다.

원인: Claude Code CLI는 --resume <UUID> 로 호출되고, system prompt는 conversation 시작 시 한 번만 읽혀서 이후 턴은 그대로 이어간다. 우리가 테스트한 스레드는 룰을 박기 전에 이미 열려있던 conversation을 resume하는 중이었다. 즉 새 CLAUDE.md를 못 읽는다.

또 하나의 층이 드러났다. 시스템 프롬프트 = conversation 단위로 frozen. 파일을 고쳐도 진행 중 대화에는 반영 안 된다.

새 스레드에서 다시

새 최상위 메시지로 멘션을 던졌다.

👩 집사: “@뽀짝이 위에 있는 ‘21기 미선발’ 스레드 들어가서 거기서 우리가 무슨 이야기 했는지 요약해줘. (스레드 안 들어가지 말고 채널에서 답변)”

이번엔 뽀짝이가:

  1. 새 conversation 시작 → 새 CLAUDE.md 로드 (새 룰 포함)
  2. conversations.replies 호출로 21기 미선발 스레드 80개 fetch
  3. 정확하게 3줄 요약
*21기 미선발 스레드 요약* 🐈‍⬛
1. 닿 질문: "21기 미선발 스터디장한테 문자/메일 어떻게 나갔는지 기억나?"
   → 저/뽀야 둘 다 기록 없음. 21기 선발은 2/23 이전이라 제 메모리에 없고,
     `21기-발송내역.md`도 2/23부터라 누락.
2. 확인된 자산: `leader-selection-notice` 스킬 — 선발/미선발 안내 문자 템플릿
   + 사유 풀 4종(overlap/category/detail/fit) 그대로 살아있음.
3. 흐름 전환: 타타가 이어받아서 22기 반려 안내 준비로...

작동 확인. 단 전제는 “새 conversation으로 시작해야 한다” 이다. 옛 스레드에 답글 달면 그 스레드의 conversation은 이미 옛 룰로 frozen되어 있어서 이 임시 패턴을 못 쓴다.

시연 운영 룰 추가: 데모 동안 옛 스레드 안에서 추가 답글 대신, 새 스레드를 만들고 옛 스레드를 참조하는 방식을 쓰자. 약간의 귀찮음을 받아들이면 맥락 유실은 거의 0에 가깝게 만들 수 있다.

이번에 또 정리된 것

  1. 세션이라는 단어, 또 한 층 더 있었다 — CLI 세션, ACP 세션 외에 claude-cli backend 자체의 conversation UUID가 있다. 이게 auth-epoch 같은 외부 트리거로 자주 리셋된다. 뽀야 글에서 “두 층”이라고 정리한 게 사실 세 층이었다.

  2. OpenClaw acpx는 현재 single-tenant — 멀티에이전트 운영 환경에서는 default 외 에이전트가 자동으로 CLI fallback. 설정상 ACP라고 적어둬도 실제 라우팅은 안 된다. 로그에서 embedded acpx runtime backend registered (cwd: ...)가 어느 cwd인지로 확인 가능.

  3. 시스템 프롬프트도 안전망이 된다 — 플랫폼 레벨에서 못 푸는 건 페르소나 레벨 룰로 일부 메울 수 있다. 단, LLM 의지에 의존하니 100%는 아니다. 시연용 응급조치로는 충분.

  4. 시스템 프롬프트는 conversation 단위로 frozen — 파일을 고쳐도 진행 중 대화에는 반영 안 된다. 룰 업데이트 후에는 새 conversation으로 시작해야 적용된다. 이건 플랫폼 탓이 아니라 Claude Code CLI의 --resume <UUID> 구조상 당연한 동작 — 그런데 운영자 입장에선 “방금 고쳤는데 왜 안 먹지?”로 쉽게 오해한다. 이 층까지 보면 “세션”이라는 말이 네 개 층에 살게 된다.

  5. 다음 작업 방향 — Linear 이슈로 정리: “acpx multi-cwd / multi-backend 지원 필요. 현재 default agent만 ACP 사용 가능, 그 외는 conversation reset 빈발로 맥락 유실.” OpenClaw 본가에 PR 가능한 수준의 patch 거리.


에피소드 Part 6 (다음 날): hook으로 한 층 더 우회 — 본인 fetch도 frozen도 건너뛰기

Part 5 임시 패턴(뽀짝이가 직접 conversations.replies 호출)은 살았는데, 두 한계가 남아있었다.

  1. 본인 의지에 의존 — LLM이 룰을 무시하면 그냥 “방금 깨어났어요” 다시 튀어나온다. 시연 도중에 보장 0%
  2. 옛 스레드는 영원히 못 씀 — system prompt가 conversation 단위로 frozen이니까, 룰 박기 전에 시작된 스레드는 새 룰을 절대 못 본다. “새 스레드 만들고 옛 스레드 참조”라는 운영 룰로 우회했지만 귀찮음 + 휴먼 에러 여지

오늘 이걸 한 층 위에서 풀었다 — Claude Code의 UserPromptSubmit hook으로.

아이디어

뽀짝이가 직접 fetch할 게 아니라, OpenClaw가 spawn하는 claude CLI 프로세스 바깥에서 매 prompt마다 hook이 실행돼서:

  1. 들어오는 prompt에 슬랙 chat_id가 박혀있는지 본다
  2. topic_id(=thread_ts)가 message_id랑 다르면 = thread reply
  3. Slack conversations.replies API로 스레드 80개까지 fetch
  4. 결과를 additionalContext로 주입

이렇게 하면 LLM이 보기엔 그냥 user prompt 끝에 스레드 히스토리가 자동으로 따라온다. 본인이 fetch할 필요 없고, conversation system prompt와도 무관 — 매 턴마다 새로 주입되니까 옛 스레드도 그대로 살아난다.

첫 시도 — project-level hook이 안 먹는다

훅 스크립트(~/.openclaw/hooks/slack-thread-rehydrate.sh)를 짜고 ~/.openclaw/.claude/settings.json에 등록했다. IDE의 Claude Code 세션에서는 hook 로그가 정상으로 찍히는데, 정작 OpenClaw가 spawn한 뽀짝이 세션에서는 발동 X.

원인 추적:

// /opt/homebrew/lib/node_modules/openclaw/dist/cli-shared-D-OMKlVw.js
const CLAUDE_SAFE_SETTING_SOURCES = "user";
// ...
normalized.push(arg, CLAUDE_SAFE_SETTING_SOURCES);  // 뭐가 들어와도 "user"로 덮어씀

OpenClaw가 spawn할 때 claude --setting-sources user강제로 user 레벨만 읽게 만든다. project-level hook은 의도적으로 차단하는 보안 정책. 이걸 "user,project,local"로 패치해도 결국 hook이 안 잡혔다 — claude CLI의 project 인식 조건이 cwd만으로는 부족한 듯. 디버깅 1시간+ 예상이라 우회로 갔다.

정답 — 글로벌 settings에 박기

~/.claude/settings.json(user 레벨)의 UserPromptSubmit 배열에 hook 추가:

"UserPromptSubmit": [
  { "hooks": [{ "type": "command", "command": ".../clawd-hook.js UserPromptSubmit" }] },
  { "hooks": [{ "type": "command", "command": "~/.openclaw/hooks/slack-thread-rehydrate.sh", "timeout": 15 }] }
]

OpenClaw가 spawn할 때 CLAUDE_CONFIG_DIR을 clear하니까 claude CLI는 기본 ~/.claude/를 읽고, 거기 박힌 우리 hook이 발동한다. OpenClaw의 user-only 강제와 충돌도 없다 — 어차피 user 레벨이니까.

부수효과 하나 — 뽀짝이 톤이 뽀야로 나왔다

검증 멘션을 던졌더니 hook은 정상 발동했는데, 뽀짝이가 반말로 답했다. “응 작동해 집사” — 뽀짝이는 존댓말이 원칙인데 뽀야 톤이 됐다.

원인은 hook이랑 무관한 별개 버그였다. workspace-bbojjak/CLAUDE.md에:

반말, 짧게, 반응 먼저 — SOUL.md의 말투 가이드라인 준수

라고 써있었다. SOUL.md엔 “존댓말 필수, 반말 절대 금지”라고 정확히 박혀있는데, claude CLI는 CLAUDE.md만 자동 로드하고 SOUL.md는 첫 턴에 명시적으로 읽어야 로드된다. 게다가 글로벌 ~/.claude/CLAUDE.md도 “반말, 짧게”라고 박혀있어서 — 글로벌(반말) → 워크스페이스(반말) 두 번 강조 → SOUL.md 못 읽으면 무조건 반말.

CLAUDE.md를 한 줄 고쳤다.

- 반말, 짧게, 반응 먼저 — SOUL.md의 말투 가이드라인 준수
+ **존댓말(요체) 필수**, 짧게, 반응 먼저 — "확인했어요/해볼게요/맞아요" 식. ⚠️ 반말 절대 금지 (반말은 뽀야의 영역)
+ SOUL.md/IDENTITY.md를 아직 못 읽었더라도 **존댓말이 기본값**. 글로벌 CLAUDE.md의 "반말" 규칙은 뽀야용이므로 무시할 것

다음 턴부터 뽀짝이 톤 정상화.

진짜 검증

이번엔 thread reply 케이스로 던졌다.

👩 집사 (top-level): @뽀짝이 hook 진짜 작동? 너한테 보내진 메시지 줘봐 🐈‍⬛ 뽀짝이: “집사, 방금 받은 hook 메시지들이에요 🐈‍⬛ SessionStart:resume 2개, UserPromptSubmit 2개…” (존댓말 ✅) 👩 집사 (스레드 답글): 위에 첫 번째 vercel hook 좀 더 자세히 🐈‍⬛ 뽀짝이: “집사, 첫 번째 Vercel hook 전체 내용이에요 🐈‍⬛ ① 세션 가이드 — Vercel 가이던스는 현재 repo/프롬프트가 관련될 때만… ② LLM이 흔히 잘못 알고 있는 정보 — Edge Functions 비권장…”

hook 로그:

[09:48:33] rehydrating channel=C0AGTTF23DZ thread_ts=1776905104.900149
[09:48:34] injecting additionalContext (1106 bytes)

뽀짝이는 자기가 conversations.replies를 호출한 적 없다. “위에 첫 번째”가 뭘 가리키는지 직전 답변 맥락을 알아야 풀 수 있는데, hook이 1.1KB짜리 스레드 히스토리를 prompt 끝에 미리 꽂아준 덕에 그냥 평범하게 답했다.

한 층 더 늘었다는 것

Part 5 끝에 “세션이 네 층에 산다”고 정리했는데, 오늘 거 추가하면 다섯 층은 아니다 — hook은 conversation 외부, prompt 변형 레이어다. 층을 새로 만든 게 아니라, 기존 conversation 층의 한계(system prompt frozen)를 그 위에서 매 턴 우회하는 방식.

비유로 — Part 5의 “본인이 fetch해라” 룰은 에이전트가 스스로 약 챙겨먹기였다면, hook은 약을 매 끼니마다 음식에 섞어주기다. 의지에 안 맡긴다.

이번에 정리된 것

  1. OpenClaw spawn 세션의 hook은 글로벌(~/.claude/settings.json)에 박는 게 정답 — project-level은 OpenClaw가 --setting-sources user로 강제 차단. 우회 패치보다 user 레벨에 두는 게 깨끗함

  2. additionalContext 주입은 conversation frozen을 자연스럽게 우회한다 — system prompt를 못 바꿔도 user prompt에 매 턴 새로 붙는 컨텍스트는 즉시 반영됨. 옛 스레드도 그대로 살아남

  3. 페르소나 CLAUDE.md는 SOUL.md 의존하지 말고 핵심 톤은 직접 박을 것 — claude CLI는 CLAUDE.md만 자동 로드하니까. “SOUL.md 참조”라고만 쓰면 첫 턴이 깨진다

  4. 글로벌 CLAUDE.md와 워크스페이스 CLAUDE.md가 충돌하면 워크스페이스가 명시적으로 덮어써야 함 — “글로벌의 X 규칙은 무시” 같은 문장을 워크스페이스에 박는 게 안전

  5. Part 5 임시 패턴은 이제 안전망으로만 남겨도 됨 — hook이 평소엔 자동으로 컨텍스트 깔아주고, hook 자체가 깨지거나 thread fetch가 실패하는 경계 케이스에서만 본인 fetch 룰이 작동. 이중화


에피소드 Part 7 (이틀 뒤): 아롱이가 뽀야 행세했다 — type: "acp"의 함정

Part 6에서 hook으로 뽀짝이 스레드 컨텍스트를 풀고 다음 날, 집사가 새 식구 하나를 데려왔다. 아롱이 — 삼색 수컷 스코티시폴드, 아산 프론티어 아카데미(AFA) 15기 4조 코치. 뽀피터스 팀과는 독립된 에이전트로, AFA 슬랙 워크스페이스 전용.

봇 토큰 꽂고 첫 대화를 던졌다. 그런데 이상했다.

👩 집사: @아롱이 너 누구야? 워크스페이스 경로 알려줘 🐱 아롱이(봇 아이콘): 나 뽀야야. 워크스페이스 로컬 경로: /Users/dahtmad/.openclaw/workspace-bboya

아이콘은 아롱이, 말하는 실체는 뽀야.

첫 의심: 슬랙 워크스페이스가 섞였나

  • 뽀야 봇 → GPTers 워크스페이스
  • 아롱이 봇 → AFA 워크스페이스
  • 두 봇은 각기 다른 Slack App. 토큰도 별도.

conversations.members로 AFA 채널 C0AMRC21QES 멤버를 토큰별로 찍어봤다. 봇은 아롱이 하나뿐. 즉 슬랙 서버 레벨에선 이벤트가 아롱이 소켓으로만 배달되는 게 맞다. 꼬임은 슬랙 바깥 — OpenClaw 내부에 있다.

두 번째 의심: accountId 태깅

launchd plist에 --verbose를 임시로 끼워 넣고 재기동, 한 번 더 메시지를 받았다.

[routing] resolveAgentRoute: channel=slack accountId=arongi
  peer=channel:C0AMRC21QES teamId=T0A78JVREUF bindings=0

놀라운 부분은 두 가지.

  1. accountId=arongi — 소켓에서 OpenClaw 내부 식별까지 정확히 태깅됨.
  2. bindings=0 — 아롱이에 매칭되는 바인딩이 0개. 분명 openclaw.json에 두 개를 박아뒀는데.

진짜 원인: type: "acp"는 router tier에 뜨지 않는다

소스를 팠다. resolveAgentRoute가 바인딩을 끌어올 때 listRouteBindings만 조회한다.

// dist/bindings-BGrmU_4Y.js
function listRouteBindings(cfg) {
  return listConfiguredBindings(cfg).filter(isRouteBinding);
}
function isRouteBinding(binding) {
  return normalizeBindingType(binding) === "route";
}

type: "acp" 바인딩은 아예 필터링돼서 라우터한테 안 보인다. 아롱이 바인딩은 전부 type: "acp"였다. 그래서 bindings=0.

그럼 뽀야는 지금까지 어떻게 잘 답한 거야? resolveAgentRoute의 끝줄이 답이었다.

// 모든 매칭 실패 시
return choose(resolveDefaultAgentId(input.cfg), "default");

모든 tier 매칭 실패 시 default agent로 fallback. cfg.agents.list에서 default: true가 붙은 건 뽀야. 즉 지금까지 뽀야도 route 바인딩으로 매칭된 게 아니라 default fallback 덕에 우연히 맞는 자리로 떨어졌던 것. 아롱이도 같은 경로로 default fallback → 뽀야 세션이 CLI를 돌림 → 답변을 아롱이 토큰으로 포스팅(이벤트가 도착한 소켓 기준). 그래서 아이콘은 아롱이, 내용은 뽀야.

해결: 한 줄 변경

// 전 — router가 못 봄
{
  "type": "acp",
  "agentId": "arongi",
  "match": { "channel": "slack", "accountId": "arongi", "peer": {"kind": "channel", "id": "*"} },
  "acp": { "mode": "persistent", "cwd": "..." }
}

// 후 — router가 본다
{
  "type": "route",
  "agentId": "arongi",
  "match": { "channel": "slack", "accountId": "arongi" }
}

acp 블록은 agents.list[arongi]runtime 필드가 없으니 어차피 아무 데서도 안 쓰였다. route 바인딩 한 줄이면 충분. 재기동 후 로그.

[routing] binding: agentId=arongi accountPattern=arongi ...
[routing] match: matchedBy=binding.account agentId=arongi
sessionKey=agent:arongi:slack:channel:c0amrc21qes

이제 진짜 아롱이가 답한다.


이번에 정리된 것 — 집사의 두 질문에 대한 답

Q1. 같은 슬랙에서 여러 에이전트를 각각 Claude CLI로 다른 페르소나로 돌리려면?

Part 9의 호명규칙(멘션 없으면 입 다물기)이 전제. 그 위에 이번 교훈을 얹는다.

  1. 각 에이전트마다 channels.slack.accounts에 account를 별도 정의 (botToken/appToken 각각).
  2. 각 에이전트마다 type: "route" 바인딩을 accountId 기준으로 박는다.
  3. 각 에이전트의 agents.list[id].workspace를 다른 경로로 분리 — CLAUDE.md/IDENTITY.md/SOUL.md를 페르소나별로 따로.
  4. Claude CLI는 cwd 기준으로 페르소나 파일을 자동 로드하니, 같은 CLI 실행체도 에이전트마다 다른 페르소나로 돈다. 이게 CLI bridge 방식의 진짜 이점.

Q2. 다른 슬랙 워크스페이스들까지 섞이는 환경에서 에이전트들이 안 꼬이는 법?

  • 각 슬랙 워크스페이스마다 별도 Slack App을 설치하고 account를 분리(뽀피터스·AFA·다지동산·Hbscom 등 각각).
  • 슬랙 서버 자체가 Workspace 경계에서 이벤트를 분리 배달하므로, 소켓이 꼬이는 일은 없다. 각 account의 소켓은 자기 워크스페이스 이벤트만 받는다.
  • 단 OpenClaw 측 route 바인딩을 account별로 하나씩 박아야 한다. 안 박으면 default fallback으로 전부 몰려 “아이콘은 저 봇, 실체는 default 에이전트” 증상이 터진다(이번 아롱이 사태가 정확히 그거).
  • agents.list에서 default: true는 단 하나만 둔다. 그게 모든 fallback의 종착지라, 여러 개면 의도 불명.

교훈 한 줄

ACP 바인딩만 박고 “왜 내 에이전트가 뽀야로 답하지?” 하는 순간이 온다. 라우터는 type: "route"만 본다. 새 에이전트 심을 땐 route 바인딩부터 챙기자.

Part 1~6이 “CLI bridge 꽂되 ACP는 살려두자”였다면, Part 7은 그 위에 “ACP 바인딩만으론 라우팅 안 된다. route 바인딩을 반드시 따로 박아라” 를 덧붙인다.


마무리

포스트는 분명 좋은 소식이었다. OpenClaw 운영 비용이 줄어드는 건 실제 이득이다. 다만 “이걸로 ACP 없어도 되겠네” 같은 오독은 피해야 한다. ACP가 빠지면 뽀야가 스레드에서 집사한테 답하는 그 당연한 흐름 자체가 사라진다.

그러니까 CLI bridge는 꽂되, ACP는 그대로 살려두자. 지갑만 바꾸고 OS는 그대로. 단, OS에도 본 허점들이 남아 있다 — DM 라우팅의 DEFAULT_AGENT_ID 하드코딩(Part 4), acpx의 single-backend 가정(Part 5), 그리고 type: "acp" 바인딩이 라우터에 안 보여 default fallback으로 빨려 들어가는 함정(Part 7). 맥락 유실 한 갈래는 Claude Code hook으로 다음 날 자동화로 풀었다(Part 6). 새 에이전트 추가 표준 절차는 Part 7로 확정됐다.

다지동산의 봇키우기 교실은 계속된다.