요약
지피터스 22기 2~3주차("내 자동화에 인터페이스 입히기")를 따라가며, 이틀(2026-06-02~03) 동안 텔레그램 PKM 봇을 격리된 빈 봇 → 내 vault를 읽고 쓰는, 나를 닮은 비서로 키웠다.
starter kit 코드가 없어 강의를 거울 삼아 Bun + Claude Agent SDK로 봇을 직접 짰고, 그 사이에 Bun+Hono API 서버를 끼워 입구 를 늘렸으며, "WebFetch로 POST가 된다"는 강의의 단순화를 타입 정의로 반증하고 SDK 커스텀 툴로 진짜 메모 저장을 완성했다.
유튜브 자동수집을 붙이며 만난 세 겹의 실패는 재현 하니스로 끝까지 추적했고, 마지막엔 systemPrompt 한 단락으로 봇에 내 가치관(경청·평정심·아모르파티)을 입힌 뒤 독립 git 레포로 토큰 위생까지 정리했다.
비개발자인 내가 빈 폴더에서 시작해 모바일 입구 → API 창구 → vault 직접 쓰기 → 페르소나까지 손으로 깐 기록.
1. 소개 — 무엇을 시도했고, 왜 했나
📌 이 글은 시리즈 2편입니다.
1편 [PKM 볼트에 그물을 짜다 — 22기 1주차]([1주차 글 링크 ←게시 후 URL 붙여넣기])에서 vault 안쪽에 백링크 그물을 짜 정보를 연결했다면,
이번 2편은 그 vault에 바깥에서 닿는 모바일 입구를 답니다. 흐름은 1편(내부 정리) → 2편(외부 연결) → 다음 4주차(MCP로 AI가 직접 vault 도구 호출) 로 이어집니다.
시작점의 불편
그동안 Claude로 내 PKM(Obsidian vault)을 다루려면 PC 앞에 앉아 있어야 했다. 길에서 떠오른 생각을 바로 저장하거나, 내 기록("오늘까지 몇 번 뛰었지?")에 폰으로 묻는 길이 없었다. 자동화의 가치는 언제 어디서나 닿을 수 있는 입구에서 폭발하는데, 내 자동화에는 그 입구가 없었다.
강의가 준 한 줄의 뼈대
지피터스 22기 강의가 "입구를 하나에서 여러 개로"라는 한 줄로 4주 전체를 꿰뚫었다. 이 흐름을 연습용이 아니라 내 진짜 vault에 적용하기로 했다.
2주차 — 텔레그램 봇이라는 모바일 입구 만들기
3주차 — API 서버를 끼워 같은 데이터에 입구를 둘로 (브라우저 화면 + 봇)
(다음) 4주차 — MCP로 AI가 직접 내 도구를 부르게
첫 장벽
강의가 가리키는 starter kit 저장소 링크를 못 찾았다. Bun도 미설치 상태였다. 그래서 코드를 받아 채우는 대신, 강의 11장을 거울 삼아 같은 폴더 구조를 처음부터 직접 작성하기로 했다. 결과적으로 이 "막힘"이 가장 큰 배움을 줬다.
진행 방법 — 도구와 워크플로우
사용한 도구
도구
역할
메모
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을 손으로 따라가며 전부 실제로 동작시켰다:
서버 (04) —
bun create hono→ "Hello Hono!" 확인,/hello/:name으로 URL 변수가 코드로 들어오는 걸 봄DB (05) — Bun 내장 SQLite로
notes테이블 생성.?자리표시자로 SQL 주입 방어CRUD 5개 (06) — GET목록·GET하나·POST·PATCH·DELETE. 노트 하나의 일생(만들기→조회→수정→삭제→404)을 curl로 한 바퀴
검증 (07) — Zod
safeParse로 나쁜 입력(title 빈 값, body 숫자)을 DB 닿기 전 차단. 500으로 터지던 게 "어디가 왜 틀렸는지" 담은 400으로화면 (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 커스텀 툴 → 코드 안에서
fetchPOST, 이스케이프 문제 없음
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 한 단락이면 충분했다.
2. 결과와 배운 점
정량 결과 (이틀 누적)
항목
결과
만든 것
텔레그램 봇 + 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 'gpters-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. 도움 받은 글 / 참고
이재엽+온어닷 스터디장님의 지피터스 22기 인터페이스 레이어 강의 자료 (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)이 가장 큰 배움을 줬다. 만드는 것만큼 닫는 것도 작업이다." 📲