GITHUB 레포 딥다이브 · 2026-08-03 · TRYCOMPAI/CRM · CRM을 "폼 얹은 DB"에서 뒤집어 — 지속형 리서치 에이전트를 제품 본체로 삼은 오픈소스

trycompai/crm 딥다이브
"에이전트가 제품이고, 데이터베이스는 그 에이전트가 메모를 적는 곳이다"

trycompai/crm은 보안·컴플라이언스 스타트업 Comp AI가 공개한 에이전트 우선(agentic-first) CRM이다. 보통 CRM은 "사람이 채워야 하는 빈 양식(폼)을 얹은 데이터베이스"이고, AI CRM은 그 옆에 챗박스를 하나 붙인 것이다. 이 프로젝트는 그 구도를 통째로 뒤집는다 — 스스로 도는 지속형 리서치 에이전트가 제품의 본체이고, 데이터베이스는 그 에이전트가 알아낸 것을 적어 두는 공책일 뿐이다.

에이전트는 요청-응답(request-response)이 아니다. 자기 배포판에서, 자기 스케줄로, 자기 작업 큐를 상대로 돈다. 다음에 무엇을 볼지 스스로 정하고, 후속 확인을 스스로 예약하고, 리서치 예산을 쓰다가 예산이 떨어지면 멈춘다. 브라우저를 닫아도 계속 일한다. 그리고 이 시스템이 지키는 단 하나의 규칙 — 사람에 관한 것은 아무것도 추측하지 않는다. 어떤 도구도 "확신도(confidence)"를 입력받지 않는다. 도구는 자기가 관찰한 것만 보고하고, 그 증거의 값은 원장(ledger)이 매긴다.

(저장소 trycompai/crm · 라이선스 MIT · 런타임 Bun 1.3.12 / Node ≥22 · 모노레포 Turborepo · 3앱 agent·api·app + 5패키지 · 에이전트 프레임워크 eve ^0.29.4 · 도구 19개·스킬 4개 · Prisma 7.9 / Postgres 17 · Better Auth(Google 전용) · 최신 커밋 2026-08-02 · TrendShift Daily #3)
목차
  1. 한 줄 정체
  2. 왜 주목받는가 — "폼을 채우지 말고, 에이전트가 알아내게 하라"
  3. 기술 스택 전체 지도
  4. 아키텍처 심화 — 일 큐, 증거 원장, deny-all 샌드박스
  5. 디렉토리 구조 해부
  6. 학습 포인트 — 기술별 배울 것
  7. 시스템 / 배포 요구사항
  8. 직접 해볼 수 있는 실습 과제
  9. 관련 기술 심화 로드맵 (주차별)
  10. 핵심 키워드 사전
  11. 참고 링크

1한 줄 정체

이 프로젝트가 정확히 무엇인지 한 문장으로

trycompai/crm은 "영업 담당자가 연락처·회사·거래 정보를 손으로 채우는 대신, 스스로 도는 리서치 에이전트가 이메일 스레드·회의·서명란·웹을 뒤져 '이 사람이 누구인지'를 알아내고 증거와 함께 기록하는, 에이전트 우선(agentic-first)의 오픈소스 CRM"이다. 핵심 뒤집기 한 줄로 요약하면 — 에이전트는 CRM의 기능이 아니라, CRM이 에이전트의 메모장이다.

한 장의 비유

"혼자 알아서 조사하는 리서치 조수 — 빈 양식을 내미는 접수대가 아니다"

보통 CRM은 접수대의 빈 서류철이다. 담당자가 명함을 받아 이름·회사·직함을 칸칸이 손으로 옮겨 적어야 한다. 안 적으면 비어 있고, 잘못 적으면 잘못인 채로 남는다.

이 프로젝트는 접수대가 아니라 책상 하나를 통째로 차지한 리서치 조수다. 새 연락처가 들어오면 조수가 알아서 "이 사람 누구지?"를 조사한다 — 우리가 이미 가진 메일 스레드를 읽고, 서명란을 보고, 필요하면 LinkedIn·웹까지 확인한다. 알아낸 것은 "내가 이렇게 봤다"는 증거와 함께 기록하고, 확실치 않으면 담당자에게 "이 둘 중 누구입니까?"라고 되묻는다. 당신이 자리를 비워도(브라우저를 닫아도) 조수는 자기 할 일 목록을 보며 계속 일한다.

용어
CRM (고객 관계 관리, Customer Relationship Management)
회사가 잠재/기존 고객의 연락처·거래·대화 이력을 모아 두는 시스템. Salesforce·HubSpot이 대표적. 대개 "사람이 입력하는 데이터베이스 + 대시보드"의 형태다. 이 레포는 그 입력 노동 자체를 에이전트에게 떠넘긴 실험이다.
용어
지속형 에이전트 (durable agent)
한 번의 요청에 한 번 답하고 끝나는 챗봇과 달리, 세션이 재배포·재시작을 견디고 하던 일을 이어서 하는 백엔드 에이전트. "브라우저를 닫아도 계속 돈다"가 가능한 이유. 이 레포는 Vercel의 eve라는 지속형 에이전트 프레임워크 위에 지어졌다(4장).

연락처 하나가 새로 생겼을 때 시스템 안에서 벌어지는 일을 압축하면 이렇다. 왼쪽(전통 CRM)과 오른쪽(이 레포)의 대비가 핵심이다:

전통 CRM trycompai/crm (agentic-first) ───────────── ──────────────────────────── 새 연락처 새 연락처 │ │ ▼ ▼ 사람이 폼을 연다 API가 "무슨 일이 일어났다"만 큐에 기록 │ │ (agentTask 한 행) ▼ ▼ 이름/회사/직함을 손으로 타이핑 매분 도는 dispatch가 그 행을 lease │ │ ▼ ▼ 저장 → 끝 (비면 비고, 틀리면 틀림) 에이전트 세션이 조사: 스레드·서명·웹 │ ▼ record_fact(값 + 증거) → 원장이 채점 │ ┌─────┴─────┐ ▼ ▼ 강한 증거 약한 증거 → 기록에 확정 → 담당자에게 "이 사람 맞나요?" 제안

오른쪽 흐름의 각 마디 — 큐, lease, 조사 세션, 증거 원장 — 가 이 레포에서 배울 만한 설계의 전부다. 4장에서 하나씩 뜯어본다.

2왜 주목받는가 — "폼을 채우지 말고, 에이전트가 알아내게 하라"

트렌딩 이유와, 경쟁 제품(전통 CRM · AI 챗봇 CRM) 대비 각도

이 레포가 뜬 이유는 기능이 많아서가 아니다. 별(star)은 이 글을 쓰는 시점에 아직 두 자릿수인 신생 저장소다(TrendShift의 굵은 숫자는 별 수가 아니라 모멘텀 점수다). 뜨는 이유는 "AI를 CRM에 어떻게 붙일 것인가"에 대한 지금까지의 답을 정면으로 반박하기 때문이다.

구도지능이 사는 곳일하는 방식빈칸을 대하는 태도
전통 CRM없음 (사람이 지능)사람이 폼 입력사람이 채울 때까지 빈칸
AI 챗봇 CRM의 챗박스물어보면 답함 (요청-응답)여전히 사람이 채움
trycompai/crm독립 배포된 에이전트스스로 큐를 돌며 조사에이전트가 증거로 채우거나, 못 채우면 제안

README의 표현을 빌리면: "대부분의 CRM은 앞에 폼을 세운 데이터베이스다. AI CRM은 그 폼 옆에 챗박스를 볼트로 조여 붙인다. 둘 다 진짜 일 — 무엇이 사실인지 알아내고 그것을 적어 두는 일 — 은 더 나은 할 일이 있는 사람에게 떠넘긴다." 이 레포는 그 "진짜 일"을 제품의 중심에 놓았다.

주목 포인트 ① — API에는 지능을 절대 두지 않는다

백엔드(NestJS API)는 일부러 아무 판단도 하지 않는다. 스레드가 하나 수집됐다, 회사가 생성됐다, 참석자가 누군지 모른다 — 이런 "무슨 일이 일어났다"를 큐에 한 행 적을 뿐이다. 그 행이 무슨 의미인지는 에이전트가 lease해서 결정한다. Nest 서비스가 직접 enrichment API를 호출하면 그것은 버그로 취급된다. 왜 이런 규칙이 생겼는지는 docs/api.md에 "그 규칙을 만들게 한 장애(outage)" 일화로 남아 있다.

주목 포인트 ② — 확신도를 입력받지 않는 "증거 원장"

이 레포에서 가장 배울 만한 설계다. 어떤 도구도 confidence: 0.9 같은 값을 받지 않는다. 이유가 날카롭다:

설계가 피하려는 함정
"모델에게 자기 확신을 채점하라고 하면, 유용해 보이는 방향으로 틀린다"

LLM에게 "이 사실을 얼마나 확신하니?"라고 물으면 모델은 점수를 만들어낸다 — 그리고 자기가 쓸모 있어 보이는 쪽으로 틀린다. 그래서 도구는 확신도 대신 관찰한 종류만 보고한다: crm.signature-block(상대 서명란이 그렇게 적혀 있다), github.account-identity(깃허브 계정 이름이 일치한다) 같은 것.

그 증거의 무게는 코드로 고정된 원장이 매긴다. "고객에 대해 자신 있게 틀린 사실은 빈칸보다 나쁘다 — 아무도 그게 틀렸는지 알 수 없으니까."

주목 포인트 ③ — 유출 불가능한 모양의 샌드박스

에이전트에게 셸(bash·grep·glob)과 /workspace를 주되, 샌드박스는 deny-all 이그레스(egress) — 바깥으로 나가는 네트워크가 완전히 차단된다. 게다가 샌드박스에는 DATABASE_URL절대 넘기지 않는다. 크레덴셜과 송신 경로를 둘 다 가진 셸은 "유출 모양(exfiltration-shaped)"이지만, 둘 다 없는 셸은 그냥 텍스트 처리기다. 고객 이메일 본문이 셸 명령으로 빠져나갈 유일한 경로를 구조적으로 없앤 것이다.

주목 포인트 ④ — 단일 테넌트, 그리고 그것을 자랑으로 여김

이 CRM은 의도적으로 단일 테넌트(single-tenant)·내부 전용이다. 로그인은 Google 하나, 접근 허용 목록은 환경변수 딱 하나(ALLOWED_SIGN_IN), 들어온 사람은 전부 다 본다. "항상 같은 값인 organizationId는 컬럼 하나, 인덱스 하나, 권한 체크 하나를 늘리면서 아무것도 사주지 못한다"는 이유로 조직(organization) 개념 자체를 뺐다. 스타트업 하나가 자기 영업에 쓰려고 만든 물건이라는 정직함이, 역설적으로 설계를 날카롭게 만든다.

3기술 스택 전체 지도

백엔드 · 프론트엔드 · 인프라를 각각, 버전과 함께

한마디로 "Vercel 생태계로 끝까지 밀어붙인 TypeScript 모노레포"다. 런타임은 Node가 아니라 Bun, 빌드 오케스트레이션은 Turborepo, 세 개의 앱과 다섯 개의 공유 패키지가 하나의 저장소에 산다.

용어
모노레포(monorepo) · Turborepo · Bun
모노레포 = 여러 앱·패키지를 저장소 하나에 모아 두고 코드를 공유하는 방식. Turborepo = 그 안의 빌드/테스트를 의존성 순서대로 돌리고 결과를 캐시해 주는 도구(turbo run build). Bun = Node의 대체 런타임 겸 패키지 매니저 — 설치·실행이 빠르다. 이 레포는 package.jsonpackageManager로 Bun 1.3.12를 못 박아 둔다.

스택 한눈에 (README stack 표 + 실제 버전)

무엇버전 / 비고
에이전트eve — 지속형 세션·도구·스킬·스케줄·샌드박스^0.29.4 · Vercel의 파일시스템-우선 프레임워크
모델Vercel AI Gateway (@ai-sdk/gateway)공급자 SDK 없음 · Vercel OIDC라 관리할 키가 없음
샌드박스Vercel Sandbox(운영) / Docker · microsandbox(로컬)세 백엔드 모두 deny-all 이그레스
프론트엔드Next.js App Router · shadcn/ui · nuqsNext 16.2.12 · nuqs 2.8 (URL 상태)
APINestJS + nestjs-trpcNest 11 · trpc 브리지 2.13 · HTTP·인증·Google 동기화
데이터Prisma · Postgres(Neon) · 선택 Redis(Upstash)Prisma 7.9 · Postgres 17-alpine(로컬 docker)
인증Better Auth, Google 전용, 허용 목록 하나1.6 · ALLOWED_SIGN_IN 하나가 인가 모델 전부
파일Vercel Blob — 프로필/로고 미러링원본 URL이 만료돼도 사진이 남도록 복제 저장
툴링Biome · TypeScript 5.9 · Turborepo · BunNode ≥22 · 포맷/린트는 Biome 하나로 통일
용어
tRPC · nestjs-trpc
tRPC = 백엔드 함수의 타입을 프론트엔드가 그대로 가져다 쓰게 해, 스키마 파일 없이 엔드투엔드 타입 안전을 얻는 방식. 이 레포는 nestjs-trpc로 NestJS 라우터에서 AppRouter 타입을 생성해, Prisma의 DB 행부터 화면의 표 셀까지 타입이 이어진다. (생성물 apps/api/src/generated/server.ts는 커밋되며 빌드 때 재생성 금지 — 생성기가 요구하는 GLIBC가 빌드 이미지보다 높기 때문.)

왜 이 조합인가 — "관리할 키가 없다"

눈에 띄는 선택은 Vercel AI Gateway다. OpenAI/Anthropic SDK를 직접 붙이지 않고 게이트웨이를 거치면, Vercel 위에서는 OIDC로 인증되어 API 키를 코드나 환경변수에 둘 필요가 없다. 모델 선택은 DB 설정(AppSetting)에서 읽어 세션 시작 때 동적으로 바뀐다(lib/model.tsselectedModel()). 무거운 GPU도, 모델 프로바이더 계정도 이 앱이 직접 들고 있지 않다.

4아키텍처 심화 — 일 큐, 증거 원장, deny-all 샌드박스

시스템 구조도 + 이 레포에서 진짜 배울 4가지 설계 패턴

시스템 구조도 — 세 개의 배포판과 한 개의 Postgres

앱(Next.js)·API(NestJS)·에이전트(eve)는 서로 독립된 세 배포판이고, 이들이 반드시 합의해야 하는 건 DATABASE_URLBETTER_AUTH_SECRET 둘뿐이다. 모든 소통은 Postgres를 통해 간접적으로 일어난다.

┌───────────────────┐ ┌───────────────────┐ │ apps/app :3000 │ │ apps/api :3001 │ │ Next.js (화면) │◀─tRPC─▶│ NestJS │ │ URL이 곧 뷰 상태 │ │ HTTP·인증·구글동기│ └─────────┬─────────┘ └─────────┬─────────┘ │ Better Auth 쿠키 │ "무슨 일이 일어났다" │ (app이 검증) │ 만 큐에 기록 ▼ ▼ ╔═════════════════════════════════════════════════╗ ║ Postgres (Neon) ║ ║ contact · company · deal · emailThread ... ║ ║ contactFact(증거 원장) agentTask(일 큐) ║ ╚═══════════════════════▲═════════════▲════════════╝ │ lease │ record_fact │ │ ┌───────────────────────┴─────────────┴───────────┐ │ apps/agent (eve) ── 독립 배포, 자기 스케줄 ── │ │ │ │ schedules/dispatch cron "* * * * *" 매분 │ │ └─ claimDue(5) FOR UPDATE SKIP LOCKED │ │ tools/ (19) record_fact · research_person ... │ │ skills/ (4 md) evidence · identity-matching ...│ │ sandbox/ bash·grep·glob + /workspace │ │ networkPolicy: deny-all (송신 차단) │ │ DATABASE_URL 미주입 │ └──────────────────────────────────────────────────┘

패턴 ① — 자기 자신을 돌리는 일 큐 (Postgres FOR UPDATE SKIP LOCKED)

에이전트는 크론 표현식으로 "매 N분마다 오래된 연락처 10명" 같은 걸 하지 않는다. 그런 건 전부 각 작업의 dueAt(마감 시각)에 들어간다. 스케줄러(dispatch.ts)는 매분 깨어나 지금 마감된 일만 lease한다.

용어
FOR UPDATE SKIP LOCKED · lease(리스)
Postgres에서 여러 일꾼(dispatcher)이 같은 큐를 안전하게 나눠 먹는 고전적 기법. 한 일꾼이 어떤 행을 집으면 그 행을 잠그고, 다른 일꾼은 잠긴 행을 건너뛴다(skip locked) — 그래서 둘이 서로 겹치지 않는 일을 가져간다. lease는 "이 일을 10분간 내가 맡는다"는 임대 표시(leasedUntil). 일꾼이 죽어 임대가 만료되면 그 행은 자동으로 풀려 다른 일꾼이 다시 집는다. 크론·메시지큐 없이 DB 하나로 신뢰성 있는 작업 분배를 얻는다.
// lib/tasks.ts — 마감된 일을 원자적으로 lease (핵심만)
export async function claimDue(limit) {
  return db.$queryRaw<LeasedTask[]>`
    UPDATE "agentTask" AS t
    SET "leasedUntil" = ${until},                 -- 10분 임대
        "attempts"    = t."attempts" + 1
    FROM (
      SELECT id FROM "agentTask"
      WHERE "finishedAt" IS NULL
        AND "dueAt" <= ${now}                      -- 지금 마감된 것만
        AND ("leasedUntil" IS NULL OR "leasedUntil" < ${now})
        AND "attempts" < 3                         -- 3번 실패하면 포기
      ORDER BY "priority" DESC, "dueAt" ASC
      LIMIT ${limit}
      FOR UPDATE SKIP LOCKED                     -- 겹치지 않게 나눠 집기
    ) AS due
    WHERE t.id = due.id
    RETURNING t.id, t."contactId", t.kind, t.reason, t.budget;`;
}

에이전트가 나중에 이 사람을 또 보고 싶으면 schedule_recheck를 호출하며 이유를 적는다 — 그리고 그 이유가 담당자에게 그대로 보인다. "14일 뒤에 다시 볼게요"라고만 하고 이유를 못 대는 에이전트는 이유가 있는 게 아니라 그냥 기본값을 쓰는 것이기 때문이다.

패턴 ② — 확신도를 안 받는 증거 원장 (evidence.ts)

2장에서 예고한 그 원장이다. 증거는 종류(kind)별로 무게가 코드에 박혀 있고, 여러 증거는 확률적으로 결합된다. 핵심은 "독립된 소스 하나당 증거 한 개" — 같은 페이지에서 이름과 회사가 둘 다 맞는 건 증거 두 개가 아니라 한 개다(안 그러면 한 페이지를 이중으로 세어 거짓 확신이 된다).

// evidence.ts — 증거 무게(일부)와 결합
WEIGHTS = {
  "profile.email-match":        { weight: 0.95, primary: true },  // 프로필에 그 이메일
  "linkedin.employer-and-name": { weight: 0.85, primary: true },  // 이름+회사 동시 일치
  "crm.signature-block":        { weight: 0.80, primary: true },  // 본인 서명란
  "github.account-identity":    { weight: 0.80, primary: true },
  "web.cited-claim":            { weight: 0.40, primary: false }, // 보조: 혼자선 부족
  "employer-only":              { weight: 0.20, primary: false }, // 회사만 맞음(거의 무의미)
  "contradiction":              { weight: 0.00 },                // 모순: 통째로 hold
};

// 결합: 1 - ∏(1 - wᵢ)  → 증거가 쌓일수록 1에 수렴, 상한 0.99
score = 1 - evidence.reduce((rem, e) => rem * (1 - WEIGHTS[e.kind].weight), 1);
// primary 증거 없이 높은 점수만으로는 VERIFIED가 안 됨
band  = score >= 0.85 && hasPrimary ? VERIFIED
      : score >= 0.55 ? PROBABLE
      : score >= 0.30 ? POSSIBLE : null;
이 설계가 좋은 이유
"모순은 점수를 조금 깎는 게 아니라, 사실을 통째로 보류한다"

한 소스는 회사 A, 메일 헤더는 회사 B라고 말하면 그 사실은 "60% 참"이 아니라 미해결이다. contradiction 증거는 점수를 0.45로 눌러 밴드를 무너뜨린다 → 기록에 쓰지 않고 담당자에게 넘긴다. "제안(suggestion)은 실패가 아니라 종종 올바른 결과다." 같은 회사에 마르케티가 넷이면, 사람이 3초 만에 고르는 게 맞다.

패턴 ③ — 유출 불가능한 모양의 샌드박스 (sandbox.ts)

단 8줄이지만 보안 사고방식이 압축돼 있다. 세 실행 백엔드(Vercel/Docker/microsandbox) 전부에 나가는 네트워크 전면 차단을 건다.

// sandbox/sandbox.ts — 전부
export default defineSandbox({
  backend: defaultBackend({
    vercel:       { networkPolicy: "deny-all" },
    docker:       { networkPolicy: "deny-all" },
    microsandbox: { networkPolicy: "deny-all" },
  }),
});

차단이 공짜인 이유가 영리하다. web_fetch는 앱 런타임에서, web_search는 모델 공급자 쪽에서 돈다 — 즉 바깥과 통신하는 도구들은 애초에 샌드박스 밖에 있다. 그래서 샌드박스에서 네트워크를 없애도 기능은 그대로고, 사라지는 건 딱 하나 — 고객 이메일 본문이 셸 명령으로 빠져나갈 경로다. 여기에 "샌드박스에는 DATABASE_URL을 절대 주지 않는다"가 더해지면, 셸은 크레덴셜도 송신 경로도 없는 순수 텍스트 처리기가 된다.

패턴 ④ — API에는 지능이 없다 (이벤트만 기록)

NestJS API의 agent/ 모듈은 agent-queue.serviceagent-trigger.service뿐이다. 하는 일은 "연락처가 생겼다 → agentTask 한 행 넣기". 판단(누구인지, 무엇을 조사할지)은 전부 에이전트 몫이다. 이 경계가 흐려지면 무슨 일이 생기는지 docs/api.md가 증언한다: "신원 매처가 두 벌로 복제됐다가 서로 갈라져, 결국 한쪽이 지구상 모든 고용주를 일치로 판정했다." 지능을 한 곳(에이전트)에만 두는 이유다.

5디렉토리 구조 해부

모노레포 지도 — 어디에 무엇이 사는가
crm/ ├─ apps/ │ ├─ agent/ ★ 제품의 본체 — eve 지속형 에이전트 │ │ └─ agent/ │ │ ├─ agent.ts 에이전트 정의(모델 동적 선택) │ │ ├─ schedules/dispatch.ts 매분 cron, claimDue로 일 lease │ │ ├─ channels/ crm.ts·eve.ts 세션 진입/이탈 이벤트 처리 │ │ ├─ tools/ (19개) record_fact, research_person, │ │ │ identify_contact, schedule_recheck ... │ │ ├─ skills/ (4 md) evidence · identity-matching · │ │ │ data-boundaries · writing-a-brief │ │ ├─ lib/ tasks(큐) · evidence(원장) · facts · │ │ │ capabilities · model · enrichment ... │ │ └─ sandbox/ deny-all 샌드박스 + /workspace │ ├─ api/ NestJS — HTTP·인증·tRPC·Google 동기화 (:3001) │ │ └─ src/ contacts/ companies/ deals/ activities/ │ │ dashboard/ conversations/ agent/ auth/ google/ │ │ backfill/ crm/ generated/server.ts(tRPC 타입) │ └─ app/ Next.js App Router (:3000) │ ├─ app/(app)/ contacts · companies · deals · settings │ ├─ app/(auth)/ sign-in · grant-access │ └─ components/ crm/(record-sheet·timeline) · data-table ├─ packages/ │ ├─ db/ Prisma 스키마·마이그레이션·공유 Postgres 클라이언트 │ ├─ auth/ Better Auth 설정 + 로그인 허용 목록 │ ├─ ui/ shadcn/ui 컴포넌트 + Tailwind 테마 (UI의 유일 원천) │ ├─ env/ 루트 .env를 찾아 로드 │ └─ typescript-config/ ├─ .agents/skills/ 코딩 에이전트용 스킬팩 (eve·shadcn·nuqs· │ nestjs-trpc·turborepo·prisma ...) ← 아래 참고 ├─ adrs/ 설계 결정 기록 (comp-palette 등) ├─ docs/ agent.md · api.md · design.md · plan/ └─ turbo.json · biome.jsonc · docker-compose.yml · bun.lock
눈여겨볼 흔적

.agents/skills/ 아래에는 이 레포를 만드는 데 쓰인 코딩 에이전트용 스킬팩이 통째로 들어 있다 — eve, shadcn, nestjs-trpc, nuqs, turborepo, prisma-database-setup, vercel-react-best-practices, nestjs-best-practices 등. 즉 "에이전트 우선 CRM"은 코드 자체도 에이전트와 함께 지어졌다. 다만 README는 못을 박는다 — "에이전트가 쓴 PR보다, 당신이 쓴 한 문단을 더 원한다."

에이전트 도구 19개 — 몇 개만 골라 보면

도구역할
read_crm_history우리가 이미 가진 스레드·회의·서명란을 읽는다(무료, 최고의 증거)
identify_contact이 연락처가 실제로 누구인지 특정
research_person / research_company웹·LinkedIn으로 배경 조사 (키가 있을 때만)
record_fact주장 1개 + 증거를 원장에 기록 → 확정 or 제안 결정
schedule_recheck이유를 적고 다음 확인을 큐에 예약
write_brief담당자가 회의 전에 읽을 요약(brief) 작성
list_outstanding_work지금 밀려 있는 조사 대기열 확인

6학습 포인트 — 기술별 배울 것

이 레포에서 "가져갈 수 있는" 설계 지식 + 실습 아이디어

이 저장소는 "CRM 만드는 법"보다 "에이전트를 신뢰할 수 있게 만드는 법"의 교재에 가깝다. 층별로 배울 것을 정리한다.

배울 것 ①

지속형 에이전트 = 파일들 (eve 모델)

도구는 파일, 스킬은 마크다운 파일, 스케줄도 파일, 샌드박스도 파일. 에이전트가 "디렉토리 하나"라는 발상. 세션이 재배포를 견디고 하던 일을 이어 하는 durable 실행이 핵심.

실습: tools/에서 defineTool + zod 스키마 한 개를 그대로 베껴 새 도구를 만들어 보고, 에이전트가 언제 그 도구를 부르는지 로그로 관찰.

배울 것 ②

DB만으로 만드는 신뢰성 있는 일 큐

FOR UPDATE SKIP LOCKED + lease(임대) + attempts 재시도 + 멱등 예약(scheduleTask가 미완료 동일 작업이 있으면 새로 안 만듦). RabbitMQ·SQS 없이 Postgres 하나로 작업 분배·장애 복구를 얻는 범용 백엔드 패턴.

실습: claimDue를 두 세션에서 동시에 호출해도 같은 행이 두 번 안 나오는지 확인. leasedUntil을 과거로 바꿔 "죽은 일꾼"의 일이 풀리는지 재현.

배울 것 ③

확신도를 안 받는 증거 기반 확신 모델

LLM에게 confidence를 물으면 안 되는 이유, 그리고 그 대안(관찰 종류 → 코드가 무게 부여 → 확률 결합 → 밴드). AI가 데이터를 쓰는 모든 제품이 참고할 만한 안티-환각(anti-hallucination) 설계.

실습: evidence.tsWEIGHTS에 새 kind를 추가하고 무게를 조정해, 같은 증거 조합이 VERIFIED↔PROBABLE로 넘어가는 경계를 직접 흔들어 보기.

배울 것 ④

"유출 모양"을 없애는 최소권한 샌드박스

deny-all 이그레스 + 크레덴셜 미주입 = 셸에 능력을 주되 유출 경로는 구조적으로 제거. "이 조합이면 무엇이 새어 나갈 수 있는가?"를 먼저 묻는 위협 모델링 사고.

실습: 샌드박스 안에서 curl이 실패하는 것을 확인하고, 왜 web_fetch(앱 런타임)는 되는데 셸 네트워크는 안 되는지 경로를 그려 보기.

배울 것 ⑤

엔드투엔드 타입 안전 (Prisma → tRPC → 화면)

NestJS 라우터에서 AppRouter 타입을 뽑아 프론트가 그대로 소비 → DB 행 타입이 표 셀까지 이어진다. 그리고 목록 상태(필터·정렬·페이지)는 URL에(nuqs) — "뷰가 곧 링크"라 주소창 복사로 화면이 재현된다.

실습: 연락처 목록에 필터를 걸고 주소창을 복사해 새 탭에 붙여넣어 같은 뷰가 뜨는지 확인. Prisma 모델에 필드 하나를 더하고 타입이 화면까지 전파되는 경로 추적.

배울 것 ⑥

실패-닫힘(fail-closed) 단일 테넌트 인증

인가 모델이 환경변수 하나(ALLOWED_SIGN_IN). 값이 비면 아무도 못 들어온다 — 안전한 방향으로 실패한다. Google Workspace면 동의 화면을 Internal로 두어 조직 밖은 프롬프트조차 못 본다. "권한을 늘려 복잡하게"의 반대 방향.

실습: ALLOWED_SIGN_IN을 도메인/개인메일/혼합으로 바꿔 가며 로그인 허용 경계가 어떻게 변하는지 확인.

7시스템 / 배포 요구사항

로컬에서 돌리려면, 그리고 실제 배포는 어떻게

로컬 구동에 필요한 것

딱 4개의 필수 환경변수

변수무엇을 넣나
BETTER_AUTH_SECRETopenssl rand -base64 32 결과
ALLOWED_SIGN_IN허용 도메인/이메일 (예: acme.com 또는 you@gmail.com) — 인가 모델 전부
GOOGLE_CLIENT_ID / _SECRETGoogle OAuth 클라이언트(웹) — Gmail·Calendar API 활성화 필요

DATABASE_URL은 docker compose Postgres에 이미 맞춰져 있어 손댈 필요가 없다. Google 로그인이 유일한 문이므로, 이 값들이 없으면 API가 아예 뜨지 않는다(안전한 방향).

선택 능력(capabilities) — 없어도 돌아간다

핵심 설계 철학: 외부 소스는 전부 선택이며, 하나도 없어도 작동한다. 에이전트는 세션 시작 때 "이 설치본에 무엇이 켜져 있나"를 통보받아, 없는 걸 뒤늦게 하나씩 실패로 발견하지 않고 미리 계획한다.

// 시작 로그 예시 — 켜진 능력과 꺼진 능력
[agent] on   LinkedIn (RAPIDAPI_KEY)
[agent] off  Web research (PERPLEXITY_API_KEY)
[agent] off  Company brand data (CONTEXT_DEV_API_KEY)
키(선택)열어 주는 것
RAPIDAPI_KEYLinkedIn 프로필 — 이름·직함·재직 기간(신원에 권위 있음)
PERPLEXITY_API_KEY인용이 붙은 웹 리서치, LinkedIn slug를 찾는 검색
CONTEXT_DEV_API_KEY도메인에서 회사 로고·업종·위치·소셜
BLOB_READ_WRITE_TOKEN로고·프로필 사진 저장(원본 URL은 만료되므로 복제 보관)

키가 하나도 없어도 read_crm_history가 우리 자신의 스레드·회의·서명란을 읽는다 — 공짜이고, "상대 본인 주소에서 온 답장"만큼 좋은 증거는 어떤 데이터 업체도 팔 수 없다.

실제 배포

세 배포판(app·api·agent) + Postgres 하나. 셋이 반드시 합의해야 하는 건 DATABASE_URLBETTER_AUTH_SECRET뿐 — 어긋나면 에러가 아니라 리다이렉트 루프로 드러난다. Gmail/Calendar 동기화를 유지하려면 CRON_SECRET을 세우고 스케줄러가 POST /internal/sync/google를 치게 한다.

8직접 해볼 수 있는 실습 과제

난이도별 5선 — 클론 후 손으로 만져 보며 배우기

과제 1. 일단 띄우고, 씨앗 데이터 구경 난이도 ★☆☆

git clonebun installdocker compose up -d.env에 4개 값 → bun run db:deploybun run db:seedbun run dev. localhost:3000에서 그럴듯한 파이프라인을 눈으로 확인하고, 각 연락처의 Agent 탭에서 에이전트가 남긴 단계 로그를 읽어 본다.

과제 2. 능력(capability) 하나 추가 난이도 ★☆☆

lib/capabilities.ts의 배열에 새 항목(예: 가짜 MYSOURCE_API_KEY)을 추가하고, 시작 로그의 on/off 목록과 capabilitiesMarkdown() 출력이 어떻게 바뀌는지 관찰. "없는 소스를 미리 알려 주는" 설계가 코드로 어떻게 구현됐는지 체감.

과제 3. 새 에이전트 도구 만들기 난이도 ★★☆

tools/에서 read_crm_history.ts를 템플릿 삼아, 연락처의 최근 활동 N건을 반환하는 새 defineTool을 작성(zod로 입력 스키마 정의). description을 바꿔 가며 에이전트가 그 도구를 부르는 빈도가 어떻게 달라지는지 본다 — 도구 설명이 곧 프롬프트라는 걸 체감.

과제 4. 증거 원장의 경계를 흔들어 보기 난이도 ★★☆

evidence.ts에 새 EvidenceKind를 넣고 무게를 정한 뒤, test/evidence.spec.ts에 케이스를 추가해 특정 증거 조합이 어느 밴드로 떨어지는지 검증. contradiction을 하나 섞으면 점수가 어떻게 무너지는지 스냅샷으로 남긴다.

과제 5. 새 작업 종류(task kind) + 디스패처 분기 난이도 ★★★

schedules/dispatch.tswork(kind, reason) switch에 새 kind(예: "birthday-check")를 추가하고, scheduleTaskdueAt을 미래로 설정해 큐에 넣는다. 매분 도는 dispatch가 마감 시각에 그 일을 집어 세션을 여는 전체 경로를 로그로 따라가 본다.

9관련 기술 심화 로드맵 (주차별)

이 레포를 발판 삼아 "신뢰할 수 있는 에이전트 백엔드"로 나아가는 5주 코스
주차주제구체적으로
1주모노레포 & 타입 파이프라인Turborepo·Bun 워크스페이스 이해 → nestjs-trpc로 서버 타입이 프론트까지 흐르는 경로 추적. nuqs로 "URL이 상태"를 구현.
2주Postgres 동시성FOR UPDATE SKIP LOCKED·트랜잭션·advisory lock. lease/재시도/멱등 예약으로 "정확히 한 번" 비슷한 보장을 DB로 만드는 법.
3주지속형 에이전트(eve)node_modules/eve/docs/부터 정독. 도구/스킬/스케줄/채널/샌드박스를 파일로 정의하고 durable 세션을 설계.
4주증거·확신 모델링 & 평가확신도를 안 받는 원장 설계, 밴드 임계값 튜닝, eval로 회귀 방지. "제안이 좋은 결과"라는 UX까지.
5주에이전트 보안deny-all 이그레스, 크레덴셜 격리, 위협 모델링("무엇이 새어 나갈 수 있나"). 최소권한 셸과 감사 로그(hooks/audit).
이 로드맵의 관통 주제

다섯 주가 결국 한 문장으로 모인다 — "어떻게 하면 자율적으로 도는 에이전트를 믿을 수 있게 만드는가." 큐(2주)는 안 죽게, 원장(4주)은 거짓말 안 하게, 샌드박스(5주)는 못 새어 나가게 만든다. 기능이 아니라 신뢰성이 이 저장소의 진짜 교재다.

10핵심 키워드 사전

이 문서에 나온 개념 압축 정리
용어
agentic-first CRM
에이전트를 CRM의 부가 기능이 아니라 제품 본체로 두고, 데이터베이스를 그 에이전트의 기록장으로 쓰는 설계 철학. 이 저장소의 정체성.
용어
durable agent · eve
세션이 재배포·재시작을 견디고 하던 일을 이어 하는 백엔드 에이전트. eve는 그걸 파일(도구·스킬·스케줄·샌드박스)로 정의하는 Vercel의 파일시스템-우선 프레임워크(^0.29.4).
용어
agentTask 큐 · lease
해야 할 조사를 담는 Postgres 테이블. dueAt(마감)·leasedUntil(임대)·attempts(시도)로 관리되며, dispatch가 매분 마감된 것만 집는다.
용어
FOR UPDATE SKIP LOCKED
여러 일꾼이 같은 큐 테이블을 겹치지 않게 나눠 먹게 하는 Postgres 잠금 옵션. 메시지큐 없이 신뢰성 있는 작업 분배를 얻는 핵심.
용어
증거 원장(evidence ledger)
확신도를 입력받지 않고, 관찰한 증거 종류에 코드가 무게를 매겨 사실을 채점하는 evidence.ts의 구조. 환각을 막는 안티패턴의 반대편.
용어
FactBand (VERIFIED/PROBABLE/POSSIBLE)
증거 점수 + primary 증거 유무로 결정되는 사실의 등급. VERIFIED만 기록에 확정되고, 아래는 담당자에게 제안된다.
용어
contradiction hold
두 소스가 어긋나면 점수를 조금 깎는 게 아니라 사실을 통째로 보류(0.45로 눌러 밴드 붕괴). "60% 참"이 아니라 "미해결"로 다룬다.
용어
deny-all egress
샌드박스에서 바깥으로 나가는 네트워크를 전면 차단하는 정책. DATABASE_URL 미주입과 합쳐져, 셸을 "유출 불가능한 모양"으로 만든다.
용어
Vercel AI Gateway
모델 공급자 SDK 없이 게이트웨이를 통해 추론하는 방식. Vercel OIDC라 관리할 API 키가 없다(@ai-sdk/gateway).
용어
nestjs-trpc · AppRouter 타입
NestJS 라우터에서 tRPC 타입을 생성해 프론트가 소비 → DB 행부터 화면 셀까지 타입 안전. 생성물 server.ts는 커밋되고 빌드 때 재생성 금지.
용어
nuqs (URL 상태)
목록의 필터·정렬·페이지를 React 상태가 아니라 URL 쿼리에 두는 라이브러리. "뷰가 곧 링크"라 주소창 복사로 화면이 재현된다.
용어
Better Auth · ALLOWED_SIGN_IN
Google 전용 로그인 + 허용 목록 환경변수 하나가 인가 모델 전부인 단일 테넌트 인증. 값이 비면 아무도 못 들어오는 fail-closed.

11참고 링크

더 파고들 때