# 폰 한 줄로 내 PKM에 말 걸기 — 텔레그램 봇에 인터페이스·API·페르소나를 입힌 이틀

- 작성자: [느낌표](https://community.clauders.ai/member/imported-member-mTrfUN0iC1)
- 공간: [노트 / 글쓰기](https://community.clauders.ai/writing)
- 게시: 2026-06-03
- 연재: [지식 볼트와 자동화 인터페이스](https://community.clauders.ai/series/personal-knowledge-interface) 3/3편
- 태그: #인터페이스개발
- 주소: https://community.clauders.ai/writing/post/%ED%8F%B0-%ED%95%9C-%EC%A4%84%EB%A1%9C-%EB%82%B4-pkm%EC%97%90-%EB%A7%90-%EA%B1%B8%EA%B8%B0-%ED%85%94%EB%A0%88%EA%B7%B8%EB%9E%A8-%EB%B4%87%EC%97%90-%EC%9D%B8%ED%84%B0%ED%8E%98%EC%9D%B4%EC%8A%A4-api-%ED%8E%98%EB%A5%B4%EC%86%8C%EB%82%98%EB%A5%BC-%EC%9E%85%ED%9E%8C-%EC%9D%B4%ED%8B%80-31808637-be5c-894f-866c-38d71101abe1

---

> **요약**

> - 클로더즈 2~3주차("내 자동화에 인터페이스 입히기")를 따라가며, 이틀(2026-06-02~03) 동안 텔레그램 PKM 봇을 **격리된 빈 봇 → 내 vault를 읽고 쓰는, 나를 닮은 비서**로 키웠다.
> - starter kit 코드가 없어 강의를 거울 삼아 Bun + Claude Agent SDK로 봇을 직접 짰고, 그 사이에 Bun+Hono API 서버를 끼워 입구를 늘렸으며, "WebFetch로 POST가 된다"는 강의의 단순화를 타입 정의로 반증하고 SDK 커스텀 툴로 진짜 메모 저장을 완성했다.
> - 유튜브 자동수집을 붙이며 만난 세 겹의 실패는 재현 하니스로 끝까지 추적했고, 마지막엔 systemPrompt 한 단락으로 봇에 내 가치관(경청·평정심·아모르파티)을 입힌 뒤 독립 git 레포로 토큰 위생까지 정리했다.
> - 비개발자인 내가 빈 폴더에서 시작해 **모바일 입구 → API 창구 → vault 직접 쓰기 → 페르소나**까지 손으로 깐 기록.

### **사용한 도구**

도구

역할

메모

**Bun 1.3.14**

런타임

SQLite 내장(설치 불필요), macOS엔 `timeout` 없음

**Claude Agent SDK 0.3.160**

봇의 두뇌

**API 키 불필요** — 내 `claude` CLI 로그인이 곧 인증, subprocess로 호출

**Hono**

API 서버

`bun create hono`로 빈 서버 생성

**Zod**

입력 검증

`safeParse`로 DB 닿기 전 차단

**yt-dlp / ffmpeg**

유튜브 자막

`2025.11.12` 버전

**Telegram Bot API**

모바일 입구

long polling

**Obsidian**

메모가 보이는 곳

마크다운 이중저장의 종착지

### **이틀의 워크플로우 (5단계)**

```
[6/2 화]
1. 봇 탄생   → Bun+SDK로 텔레그램 봇 직접 작성 (starter kit 없이)
            → 격리(sandbox) 발견 → additionalDirectories로 vault 읽기 개방
            → "오늘까지 몇 번 뛰었어?" → "730회" 정답
2. API 구축  → Bun+Hono /notes API: 서버→DB→CRUD 5개→Zod 검증→HTML 화면
            → 봇 systemPrompt를 "폴더 저장"에서 "API POST"로 변경
            → 입구를 봇 하나에서 브라우저+봇 둘로
3. 유튜브봇  → YouTube 링크 자동수집 분기 추가
            → 세 겹의 실패(429 / stale 프로세스 / ZodError)를 재현 하니스로 추적

[6/3 수]
4. 메모 저장 → WebFetch가 GET 전용임을 타입으로 증명
            → SDK 커스텀 툴 save_note로 돌파 (canUseTool 우회 + 직접 실행)
            → 마크다운 이중저장 + 데일리 자동 기록 + slugify 강화
5. 페르소나  → systemPrompt에 경청·평정심·아모르파티 한 단락
            → 봇을 독립 git 레포로 분리, .env 토큰 노출 0건 검증
```

### **만든 것 — 봇 폴더 구조 (강의 구조 그대로)**

```
src/
├─ index.ts            시작점 (user_id 검사 + 명령 분기 + YouTube URL 감지)
├─ config.ts           .env 읽기
├─ telegram/api.ts     텔레그램 HTTP (getUpdates / sendMessage)
├─ telegram/poller.ts  long polling 루프
├─ claude/runner.ts    Agent SDK query() ★핵심 (systemPrompt + save_note 툴)
└─ claude/permissions.ts  권한 (읽기 허용 / 쓰기 샌드박스 / MCP_ALLOW)
```

### **1) 봇은 입구, 두뇌는 내 PC의 Claude**

메시지가 흐른 길:

```
폰 → poller(long polling) → handleMessage(user_id 검사·분기)
   → runQuery(query()) → 내 PC의 claude CLI가 파일을 읽음
   → sendMessage로 폰에 회신
```

핵심은, 봇 코드 어디에도 "730회를 세는 로직"이 없다는 것. **systemPrompt 한 단락이 시켰을 뿐**이다. 그래서 한 줄만 바꾸면 봇이 _달리기 코치_로 변신한다. `ALLOWED_USER_IDS`(내 user_id)로 잠가 나만 쓸 수 있게 했다.

### **2) 격리는 안전, 개방은 의식적 결정**

처음 봇은 "6/1·6/2 기록이 없다"는 **틀린 답**을 했다. 버그가 아니라 **설계대로의 격리**였다. 봇의 작업 디렉터리가 빈 샌드박스(`data/workspaces/<chat_id>/`)라 진짜 vault를 못 봐서, 단편 정보로 _그럴듯하게 지어낸_ 것이다.

격리를 의식적으로, 필요한 만큼만 열었다:

```
읽기 → additionalDirectories로 vault 전체 허용 (읽기 전용)
쓰기 → canUseTool 게이트에서 샌드박스 경로일 때만 허용 (실제 노트 보호)
지시 → systemPrompt에 "지어내지 말고 파일을 실제로 읽어라" 명시
```

"읽기는 vault 전체, 쓰기는 샌드박스만" 한 줄이 봇을 **유용하면서 동시에 안전하게** 만들었다. 권한이 봇의 성격과 안전을 동시에 정한다.

### **3) API라는 창구를 끼우다 (3주차)**

2주차 봇은 vault 폴더를 직접 만졌다. 3주차엔 그 사이에 '창구(API)'를 하나 끼웠다. 강의 슬라이드 04~08을 손으로 따라가며 전부 실제로 동작시켰다:

1. **서버 (04)** — `bun create hono` → "Hello Hono!" 확인, `/hello/:name`으로 URL 변수가 코드로 들어오는 걸 봄
2. **DB (05)** — Bun 내장 SQLite로 `notes` 테이블 생성. `?` 자리표시자로 SQL 주입 방어
3. **CRUD 5개 (06)** — GET목록·GET하나·POST·PATCH·DELETE. 노트 하나의 일생(만들기→조회→수정→삭제→404)을 curl로 한 바퀴
4. **검증 (07)** — Zod `safeParse`로 나쁜 입력(title 빈 값, body 숫자)을 DB 닿기 전 차단. 500으로 터지던 게 "어디가 왜 틀렸는지" 담은 400으로
5. **화면 (08)** — Hono로 HTML 폼+목록 서빙. 브라우저의 `fetch("/notes")`가 곧 우리 API의 클라이언트

> **API = 동사무소 민원실 비유**: 보관실(폴더/DB)에 직접 들어가지 않고 창구에 주문만 하면, 안쪽 구조가 바뀌어도 부르는 쪽은 안 망가진다. CRUD 다섯 개가 거의 모든 API의 뼈대다 — notes를 posts·tasks로 이름만 바꾸면 그대로 재사용.

### **4) WebFetch의 한계를 타입으로 증명 → SDK 커스텀 툴로 돌파**

3주차 끝에 "봇이 진짜 POST를 보낼까?"가 미검증으로 남았다. 강의는 "WebFetch로 POST하면 된다"고 했지만, 막연한 추측 대신 **SDK 타입 정의를 직접 열었다.**

`WebFetchInput` 스키마엔 `url`, `prompt` 두 필드뿐 — **body도, method도 없다. WebFetch는 GET 전용이라 POST가 구조적으로 불가능.** 이걸 확인하고 나서야 방향이 명확해졌다. 두 갈래 중 후자를 택했다:

- curl을 Bash로 호출 → 한글·따옴표·특수문자 이스케이프 지옥, 셸 인젝션 위험
- **SDK in-process 커스텀 툴** → 코드 안에서 `fetch` POST, 이스케이프 문제 없음

```
save_note 툴 = tool(name, desc, zodShape, handler)
             + createSdkMcpServer()
             → query()의 mcpServers 옵션에 연결
```

**핵심 깨달음**: in-process MCP 핸들러는 `canUseTool` 게이트를 통과하지 않고 **직접 코드로 실행**된다. 그래서 핸들러 안에서 fs 작업을 바로 할 수 있고, 셸을 안 거치니 이스케이프 문제가 사라진다. 외부 프로세스 호출보다 SDK 커스텀 툴이 안전하고 깔끔하다.

단, 봇이 처음 `save_note`를 호출하려다 거부당했다. `canUseTool`은 **기본 거부** 정책이기 때문. `permissions.ts`에 `MCP_ALLOW = ["mcp__vault__save_note"]`를 추가해 해결했다. (SDK 커스텀 툴은 `mcp__<서버명>__<툴명>` 형식으로 노출된다.)

### **5) "저장됨"을 "보인다"로 — 마크다운 이중저장**

테스트는 성공했는데 정작 내가 메모를 못 찾았다. SQLite 안에 갇혀 있으니 Obsidian에선 안 보였다. 그래서 **마크다운 이중저장**으로 전환했다:

```
① writeBotNoteMarkdown() → 00-inbox/01-bot-note/260603-0825-제목.md
   (프론트매터 type/date/tags/source:telegram + 본문)
② appendDailyNote() → 오늘 데일리 '### 🤖 봇 메모' 마커 아래 한 줄(최신순)
③ DB 저장은 보조로 유지 (이중화)
```

위키링크 깨짐도 막았다. `[할 일] 양도세`, `Mac mini (M4 16GB)` 같은 제목이 파일명에 `[]`·`()`를 남기면 `[[...]]` 위키링크가 깨진다. slugify가 `[]()#^` 등을 공백으로 치환하도록 강화하고, 이미 만든 파일도 리네임 + 데일리 링크 갱신했다.

### **6) 페르소나 — 봇에 내 가치관 입히기**

기능 지시(vault 읽기 / API 저장 / 지어내지 말기)는 그대로 두고, systemPrompt 맨 앞에 `[말투·태도]` 한 단락만 추가했다:

- **경청·간결** — 존댓말, 말 적게 핵심만 (부처님 귀)
- **평정심** — 과한 감탄사·칭찬 삼가, "감정이 태도가 되지 않도록"
- **아모르 파티** — 실패·중단도 긍정의 한 줄로, 다시 시작하면 그만
- **직설하되 존중** — 더 나은 길 보이면 제안

> **페르소나는 코드가 아니라 말이다.** 봇의 성격을 바꾸는 데 로직이 아니라 systemPrompt 한 단락이면 충분했다.

### **정량 결과 (이틀 누적)**

항목

결과

만든 것

텔레그램 봇 + Bun+Hono API 서버 (둘 다 빈 폴더에서 직접)

봇 진화

격리 빈 봇 → vault 읽기 → API 연결 → 유튜브 수집 → 메모 저장 → 페르소나

"730회" 질의

내 실제 Daily Note 누적과 일치 (이전 격리 봇은 "기록 없음" 오답)

API CRUD

5개 전부 curl·브라우저로 동작 확인, 검증 전후 비교(500→400)

WebFetch POST

**불가능** 확정 (타입 정의로 증명)

메모 저장

마크다운(주) + SQLite(보조) 이중화, 텔레그램 왕복 3건 성공

데일리 자동 기록

3줄 (1줄 실시간 + 2줄 소급)

유튜브봇 실패 추적

3건(429 / stale 프로세스 / ZodError) 전부 규명·수정, `.vtt` 자막으로 검증

git 정리

봇 독립 레포 2커밋(`e5fc114`·`d830aaa`) + 메인 볼트 3커밋, **토큰 노출 0건**

번들 검증

86 모듈 정상 빌드

### **배운 점 (5가지)**

**1. 봇은 입구, 두뇌는 내 PC의 Claude다**

- 인터페이스와 실행을 분리해서 생각하면 전체가 명확해진다. 봇은 메시지를 전달할 뿐, 실제 일(파일 읽고 세기, vault에 쓰기)은 내 Claude가 한다. 그리고 **AI에게 시킬 일은 코드가 아니라 자연어(systemPrompt)** 다 — 730회를 세는 로직을 짠 적이 없고, 성격 전환도 한 줄이었다. 기능을 코드가 아니라 말로 정의하는 시대를 체감했다.

**2. AI는 막히면 자신 있게 지어낸다 (두 번 증명됨)**

- 데이터 접근이 막히면 모델은 "모른다" 대신 그럴싸하게 꾸며낸다. 오전엔 격리된 봇이 "6/1·6/2 기록 없음"이라 오답했고, 오후엔 봇 안의 Claude가 자기 권한 오류를 두고 "harness 버그니 Claude Code를 재시작하라"는 _오진_까지 자신 있게 내놨다. 봇 안의 모델은 자기 바깥(내 콜백 코드)을 못 본다. 격리·권한 설계 + "지어내지 마라" 지시 + **사실 확인 강제**가 방어선이다.

**3. 한계는 추측하지 말고 스펙으로 증명하라**

- "WebFetch가 안 될 것 같다"에서 멈췄으면 계속 헤맸을 것이다. 타입 정의 한 번 열어보니("method가 없다") 끝났다. 강의의 단순화("WebFetch로 POST하면 된다")를 맹신하지 않고, 실제 테스트 전엔 "미검증"으로 남겨둔 태도가 다음 날의 정확한 해결로 이어졌다.

**4. "동작한다" ≠ "쓸모 있다"**

- 메모 저장 테스트는 통과했지만, 데이터가 SQLite에 갇혀 내 워크플로우(Obsidian)에선 무용지물이었다. **사용자(=나)의 실제 동선까지 닿아야 진짜 완성**이다. 그래서 마크다운 이중저장 + 데일리 색인으로 전환했고, 비로소 "이동 중에도 두 번째 뇌에 메모를 적립하는 파이프라인"이 완성됐다.

**5. 만드는 것과 닫는 것은 다른 작업이다**

- 작동하는 코드가 곧 "정리된 코드"는 아니다. 버전관리·토큰 위생·커밋 단위가 다 별개의 마무리 노동이었다. `.gitignore` 주석에 "별도 레포로 관리"라 써둔 _의도_는 메모만으론 실현되지 않았고, 실제 `git init`을 해야 닫혔다. 커밋도 "회고/코드/잡정리"를 섞지 않고 이야기 단위로 나눠야 미래의 내가 읽기 쉽다.

### **시행착오 (숨기지 않고 다 적는다)**

> 이 프로젝트에서 진짜 배움은 매끄러운 성공이 아니라 **막히고, 헤매고, 내가 틀렸던 순간들**에서 나왔다.

**시행착오 1 — 내 손으로 봇 토큰을 채팅창에 흘렸다 (가장 아찔했던 순간)**

- 초보적이고 부끄러운 실수다. 터미널에서 `!` 명령이 줄바꿈으로 깨지면서 봇 토큰 전체가 대화창에 그대로 찍혔다. 보는 순간 가슴이 철렁했다. 토큰이 노출되면 누구든 내 봇을 조종할 수 있다. 여기서 중요한 건 그다음 행동이었다 — "괜찮겠지" 하고 미루거나 변명하지 않고, 곧장 텔레그램 `/revoke`로 토큰을 폐기·재발급하고 VS Code에서 `.env`에 직접 입력(채팅을 완전히 우회)했다.
- **교훈: 비밀(토큰)은 절대 사람이 보는 채팅·대화를 거치게 해선 안 된다. 파일에 직접 넣어라. 그리고 노출됐다면 변명보다 즉시 폐기가 먼저다.**

**시행착오 2 — 욕심이 부른 자막 폭주 (HTTP 429)**

- "한국어·영어 자막 다 받자"는 생각에 `--sub-langs "ko.*,en.*"`라고 썼는데, 이 `.*`가 yt-dlp에선 **정규식**이라 ko-ar, ko-bn… 60여 개 언어 변형을 전부 긁으려다 서버에서 429 Too Many Requests로 차단당했다. → `"ko,en"` 정확 매칭으로 좁히니 `.ko.srt`+`.en.srt`만 깔끔히 떨어졌다.
- **교훈: 와일드카드는 "편하게"가 아니라 "폭주"로 돌아올 수 있다.**

**시행착오 3 — "분명히 고쳤는데" 엉뚱한 데서 한참 헤맸다 (stale 프로세스)**

- 코드를 고쳤는데도 봇이 계속 옛 동작을 반복했다. 코드를 의심하며 몇 번을 다시 들여다봤지만 범인은 코드가 아니었다 — 봇이 `--watch` 없이 떠 있어 새 코드가 메모리에 안 올라온 것. 더 창피한 건, `ps | grep 'Clauders-vault-bot'`으로 프로세스를 찾을 때 실제 명령줄(`bun run src/index.ts`)에 프로젝트명이 없어서 "봇이 아예 안 돌고 있다"고 한 번 더 오판했다는 점이다. 결국 `lsof`로 작업 디렉터리를 확인하고서야 진짜 PID를 잡았고, `--watch`로 재기동해 끝냈다.
- **교훈: 고쳤는데 안 바뀌면 코드보다 '지금 도는 프로세스'를 먼저 의심하라. 디스크의 코드 ≠ 도는 코드.**

**시행착오 4 — AI의 자신만만한 오진을 그대로 믿을 뻔했다 (ZodError invalid_union)**

- 새 코드가 도는데도 자막 추출이 막혔다. 이때 봇 안의 Claude가 _"권한 승인 계층(harness)의 ZodError 같습니다. Claude Code를 재시작하세요"_ 라고 **아주 자신 있게** 답했다. 그럴듯해서 하마터면 따를 뻔했지만, 그건 오진이었다. 추측을 멈추고 **봇과 동일한 옵션으로 도는 일회성 로깅 하니스**를 만들어 모든 도구 결정과 에러 원문을 직접 찍었더니 진짜 원인이 드러났다: 내 권한 콜백이 `{ behavior: "allow" }`만 반환하고 `updatedInput`**이 빠진** 것. 타입엔 `updatedInput?`로 optional이라 타입체크는 통과했지만, **런타임 Zod 스키마는 사실상 요구**해서 union 어디에도 안 맞았다(`invalid_union`). 4곳을 `{ behavior: "allow", updatedInput: input }`로 고쳐 해결.
- **교훈: AI가 "harness 버그니 재시작하라"고 하면, 그 전에 _내가 그 harness에 넘긴 값_을 의심하라. 봇 안의 모델은 자기 바깥(내 콜백)을 못 본다. 원인은 대개 내 손 안에 있다. 추측 3번보다 재현 하니스 1번.**

**시행착오 5 — 토큰을 지키려던 안전 검사가 오히려 거짓 경보를 냈다**

- git 정리 중 "혹시 토큰이 커밋에 섞였나" 확인하려고 grep을 돌렸더니, `.env.example`(예시 파일)이 걸려 "토큰 포함!"으로 잘못 떴다. 안전장치 자체가 거짓 경보를 낸 것. → `grep -x "\.env"`로 정확 일치만 확인해 실제 토큰 파일은 안 들어갔음을 검증했다.
- **교훈: 안전 검사 로직 자체도 틀릴 수 있다. 경보가 떴을 때 한 번 더 정확히 확인하는 습관이 진짜로 토큰을 지킨다.**

### **왜 이것이 중요한가**

- **모바일 입구의 가치**: 이제 폰 한 줄로 떠오른 생각을 vault에 저장하고, 내 기록에 질문할 수 있다. 자동화의 가치는 언제 어디서나 닿을 수 있는 입구에서 폭발한다.
- **회고 사이클이 실제로 돈다**: 6/2 회고에서 "다음 숙제"로 남긴 WebFetch POST 미검증을, 6/3에 정확히 닫았다. 회고 → 숙제 → 다음 날 해결의 사이클이 도는 것 — 이게 PKM의 본래 목적이다.
- **비전공자의 벽 넘기**: 개발자가 아닌 내가 빈 폴더에서 시작해 읽고 쓰는 API 서버와 봇을 끝까지 띄웠다. '백엔드'라는 추상적 벽 안쪽을 직접 만져봤다.
- **디버깅 근육**: "코드가 안 돈다"의 절반은 코드가 아니라 실행 환경/계약의 문제다. 유튜브봇 실패 3건 중 2건이 코드 밖이었다.

### **앞으로의 계획**

**단기 (1주)**

- 봇 24시간 가동 (오늘 봇 메모로 남긴 Mac mini 검토와 연결)
- `/run`(오늘 달리기 저장)·`/ask`(vault 질문) 명시 명령어 추가 — 단, 미리 만들면 안 쓰는 명령어가 되니 **실제로 써보고 불편이 쌓이면** 그 패턴에 맞춰 추가
- 메모 외 입력(할 일·아이디어) 분기 처리, 자막 없는 영상 폴백

**중기 (1개월)**

- **4주차 MCP** — 봇이 했던 그 자리를 _AI(Claude)가 직접_ 차지하는 동일 구조 학습·구현. "같은 데이터에 입구가 둘"이 "AI가 내 도구를 부른다"로 확장
- 위험 도구(Bash) 승인을 텔레그램 인라인 버튼으로
- 봇 레포에 README/실행 가이드 정리

**장기 (3개월)**

- 음성 메모 → 텔레그램 → vault 자동 분류까지 한 줄로 잇기
- MCP로 감싸 Claude Desktop/Code에서 AI가 직접 내 PKM 도구를 부르게

## **3. 도움 받은 글 / 참고**

- 이재엽+온어닷 스터디장님의 클로더즈 인터페이스 레이어 강의 자료 (W2 11장 / W3 04~11편)
- 내 회고 5편 (2026-06-02~03, `30-knowledge/31-reflections/`):

  - 텔레그램봇으로 내 자동화에 인터페이스 입히기 (봇 탄생)
  - 클로더즈 3주차 API 서버 직접 구축 (입구를 여러 개로)
  - 유튜브봇 자동수집 디버깅 (세 겹의 실패 추적)
  - 텔레그램봇 메모저장 WebFetch 한계 돌파 (SDK 커스텀 툴)
  - 텔레그램봇 페르소나·git 정리 (마무리)
- 관련 허브: `30-knowledge/33-knowledge-hub/ai-automation-hub.md`

_"봇은 그냥 입구다. 실제 일은 내 PC의 Claude가 한다. starter kit이 없어도 강의를 거울 삼으면 만들 수 있고, 막힌 곳(토큰·격리·ZodError)이 가장 큰 배움을 줬다. 만드는 것만큼 닫는 것도 작업이다."_ 📲
