Architecture & Design Notes

Anthropic이 컨테이너를,
우리가 모든 것을 격리한다.

이 문서는 왜 그렇게 설계했는지 설명한다. 4계층 owner 분리, 메시지 한 건의 wire-level 흐름, closed-beta MCP 우회의 정공법.

Beta header
managed-agents
2026-04-01
Model
sonnet 4.6
$3 in / $15 out / MTok
Container
gVisor
Ubuntu 24.04 · 16C/21G
Pricing
$0.08 / hr
running 상태만
§1
Mental Model

4계층 분리.
누가 무엇을 소유하는가.

Managed Agents의 핵심 설계는 "무엇이 어디에서 owner인가"의 분리. 위 2계층은 우리 DB가, 아래 2계층은 Anthropic이 소유.

L1 Catalog · template
owner → 우리 DB

1 row = 1 service template. agents (user_id NULL) — system / skills / mcp_servers / model / metadata. Anthropic agent_id (versioned)와 1:1 매핑.

↓
L2 Entitlement · lease gate
owner → 우리 DB

user × agent 매트릭스. agent_subscriptions의 status='active'가 없으면 session 생성 자체 차단. 신규 가입 시 트리거가 default 자동 grant.

↓
L3 Session · instance
owner → 우리 DB ↔ Anthropic (양방향)

chat_sessions + vaults (1:1 user) + memory_stores (1:1 user) + environments (dev/prod). session 생성 시 vault_ids + resources 주입 → Anthropic이 OAuth refresh + memory mount 자동.

↓
L4 Container · runtime
owner → Anthropic (우리는 events 주고받기만)

gVisor sandbox per-session · /mnt/session/uploads/<file_id> · /mnt/memory/<store-name>/. billing: $0.08 × active_seconds/3600 + tokens × model rate.

①
위로 굳어지는 ID

L1 catalog는 거의 변하지 않음. L4 container는 매번 새로 생성. 위로 갈수록 stable, 아래로 갈수록 ephemeral.

②
책임 경계의 의미

우리는 누가 무엇을 빌렸는가를 안다. Anthropic은 그걸 어떻게 안전히 실행하는가를 안다. 책임 분리가 무너지면 multi-tenant도 무너짐.

③
L3의 양방향성

우리 row와 Anthropic session이 1:1로 짝지어진 유일한 계층. session_id가 wire의 모든 곳에서 식별자로 쓰임.

§2
Tenancy & Isolation

격리는 4중 + 5중.

Anthropic 격리는 컨테이너 단위. 그러나 workspace API key 1개로 모든 customer를 호출하므로 RBAC는 우리 책임. 두 격리가 합쳐져야 진짜 multi-tenant.

L4 Anthropic

컨테이너 4중 격리

Pluto Security
  1. 1
    gVisor sandbox

    per-session 컨테이너. 9p filesystem. 세션끼리 file system state 공유 X.

  2. 2
    JWT egress proxy

    모든 outbound는 proxy 경유. JWT에 allowed_hosts + session_id 박힘. 169.254.169.254 metadata 차단.

  3. 3
    TLS inspection

    CN=sandbox-egress-production CA로 outbound 본문 decrypted 검사.

  4. 4
    Vault credential proxy

    credential은 write-only. "tokens are never reachable from the sandbox" — vault에서 매칭된 token만 outbound 시 자동 inject.

L1~L3 우리 (AWC)

메시지 진입점 5중 게이트

/api/sessions/[id]/chat
  1. 1
    Auth (cookies)

    supabase.auth.getUser() → 401 if missing

  2. 2
    Ownership

    chat_sessions.user_id === auth.uid → 403. RLS도 보조하지만 명시적 검증.

  3. 3
    Subscription (lease)

    session 생성 시 active subscription 필수. 미임대면 throw AGENT_NOT_LEASED → 403.

  4. 4
    Quota cap

    일반 100K/1M, admin 1M/10M tokens. 초과 시 429 + 한국어 안내.

  5. 5
    Vault per-user

    ensureVault({userId}) → vlt_xxx. session.create에 vault_ids 주입 → 매칭 credential만 outbound 시 자동 inject.

책임 분담 매트릭스

Anthropic 격리는 강력하지만 RBAC는 안 해줌. 그래서 우리가 이중으로 막아야 함.

차원 Anthropic (L4) 우리 (L1~L3)
컨테이너 격리 (file/network)✓ gVisor + JWT egress—
credential write-only 보관✓ Vault— (hand-off)
OAuth refresh 자동✓ Vault—
RBAC (사용자 데이터 분리)✗ workspace key 1개 공유✓ ownership 매 호출 검증
사용량 cap (per-customer)✗ session 누적만✓ user_quotas 일/월
결제·임대 게이트✗✓ agent_subscriptions
데이터 retentionevents.list 무기한 / container 30d✓ archive·delete cron
PIPA·GDPR 동의✗✓ 약관 응대
⚠ 황색 행 — Anthropic이 안 해주는 영역 = 우리가 이중으로 막아야 하는 곳
§3
Lifecycle of a Message

메시지 한 건의 wire 흐름.

AI SDK UI message stream wire (v1)을 따라 start → text-delta* → tool-* → finish 시퀀스 보장. 그 사이 Anthropic typed event를 우리 wire format으로 변환.

단계 0~2 · 우리 백엔드
  1. 0Pre-flight — auth → ownership → subscription → quota → vault (5 게이트)
  2. 1Persist user message — chat_messages insert + chat_sessions update (status=running, +title if first)
  3. 2Anthropic stream open + send — events.stream(sid) 후 events.send(user.message)
단계 3~4 · 양방향 + 종료
  1. 3Event loop — typed switch (8 case) → SSE chunk 변환
  2. 4Idempotent finalize — assistant insert (anthropic_event_id UNIQUE) + status=idle
단계 3 · event loop body
switch (ev.type)
typed: BetaManagedAgentsSessionEvent
case 'agent.message':                     // 텍스트 토큰
   firstAssistantEventId ??= ev.id           ← idempotency anchor
   for (block of ev.content) if (block.type==='text') {
     if (!textOpen) sse({ type:'text-start', id:textId }); textOpen=true
     assistantBuffer += block.text
     sse({ type:'text-delta', id:textId, delta:block.text })
   }

case 'agent.thinking':                    // architecture-level 미노출
   sse({ type:'reasoning-start/-delta/-end' })  // placeholder UX

case 'agent.tool_use' / mcp_tool_use / custom_tool_use:
   toolCallId = ev.id                ← tool_use는 자기 id
   sse({ type:'tool-input-start/-delta/-available' })

case 'agent.tool_result':                 // tool 결과
   toolCallId = ev.tool_use_id       ← 참조 키 (자기 id 아님!)
case 'agent.mcp_tool_result':
   toolCallId = ev.mcp_tool_use_id   ← 별도 이름

case 'span.model_request_end':             // billing anchor
   chat_sessions.usage += ev.model_usage
   user_quotas.day_used + month_used += sum    ← cap counter

case 'session.status_idle':                // happy path 종료
   chat_messages.insert({
     role:'assistant', content:{...},
     anthropic_event_id: firstAssistantEventId  ← UNIQUE
   })  // 23505 → 무시 (replay 안전)
   chat_sessions.status = 'idle'; sse({ finish }); return

case 'session.status_terminated' | 'session.error':
   chat_sessions.status = 'terminated'; sse({ error }); return
🔁
Idempotency

UNIQUE on anthropic_event_id → 재실행 시 23505 무시. vault/memory ensure 헬퍼도 race tolerant. bootstrap diff-update로 멱등.

🌳
Status FSM
     idle ←──┐
      │       │
      ↓       │
   running ──┤  (msg)
      ├→ rescheduled (transient)
      └→ terminated (irreversible)
⏸
Interrupt

useChat.stop()은 클라이언트 fetch만 끊음 → 서버 agent 계속 = 비용 누수. POST /interrupt로 user.interrupt event 별도 호출 필수.

Trap #8 tool_use_id 매핑 룰

agent.tool_use의 자기 id(ev.id)가 agent.tool_result의 ev.tool_use_id 참조 키. 양쪽 다 ev.tool_use_id로 매핑하면 fallback id 불일치 → "No tool invocation found for ID" 에러. mcp_tool은 ev.mcp_tool_use_id 별도.

§4
MCP Wrapper Protocol

closed-beta 우회.
자체 MCP server를 운영한다.

Figma 공식 MCP는 closed beta — third-party OAuth/PAT 거부. 그래서 사용자 OAuth token은 우리가 보관, /api/mcp/figma를 우리가 직접 호스트. Anthropic은 우리 server만 안다 — figma OAuth token은 우리만 안다.

A Connection 등록 — 사용자 1회
Browser              Our Backend                           Figma                Vault

/connections  ─click→ /api/auth/figma/start
                          └─ randomBytes(24) → state cookie
                          └─ redirect ────────────────────→ OAuth consent
                                                              ↓
                      /api/auth/figma/callback ←─────────────  ?code=&state=
                          ├─ verify cookie state (CSRF)
                          ├─ exchangeCode(code) ─────────→  access_token / refresh
                          ├─ user_connections.upsert (figma OAuth)
                          ├─ awc_figma_<rand24> 발급
                          ├─ mcp_user_tokens.upsert (token ↔ user_id)
                          └─ vault.credentials.create ──────────────────────────→ static_bearer
                                                                                    mcp_server_url=<our>/api/mcp/figma
                                                                                    token=awc_figma_xxx
B 런타임 호출 — agent가 figma_get_me 부를 때
Anthropic infra              Our MCP server (/api/mcp/figma)              Figma REST API

POST /api/mcp/figma   ────→
  Authorization: Bearer awc_figma_xxx   ← vault에서 매칭된 credential 자동 inject
                                ↓ resolveUserToken(bearer)
                                ↓ mcp_user_tokens → user_id
                                ↓ user_connections.access_token (figma OAuth)
                                ↓ JSON-RPC handler:
                                    initialize / tools.list / tools.call
                                    executeFigmaTool(name, args, figmaToken) ────→ GET /v1/me
                                                                              ←──── result
                                ↓ mcp_request_log.insert (디버그)
                          ←  { content:[{ type:'text', text:JSON(result) }] }
왜 이 패턴인가
  • · Anthropic이 외부 서비스 token 직접 안 봄 (vault에 opaque token만)
  • · token rotation·rate-limit·로깅 책임 우리
  • · closed/제한 MCP 정책과 무관
JSON-RPC method 3종
  • · initialize — protocolVersion 2025-06-18
  • · tools/list — 6 (figma) / 4 (veo)
  • · tools/call — bearer 검증 → execute
vault 매칭 규칙
  • · 매칭 키: mcp_server_url
  • · 같은 URL 기존 credential은 archive 후 신규 create
  • · 여러 vault 매칭 시 첫 번째 wins
Trap #17 mcp_servers + tools.mcp_toolset 동기 추가 필수

agent.mcp_servers에 server 추가만 하면 SDK 400 — "mcp_servers declared but no mcp_toolset in tools references them". tools 배열에도 { type:'mcp_toolset', mcp_server_name:'figma' } 동시 추가.

§5
Catalog & Versioning

Idempotent diff-update.
pnpm bootstrap.

카탈로그 entry는 scripts/agents-catalog.ts에 코드로. bootstrap이 retrieve → diff → update만 발생 시 version bump.

scripts/agents-catalog.ts
entry shape
{
  key: 'default',
  name: 'AWC Lease',
  display_name: 'AWC 범용 (Default)',
  model: 'claude-sonnet-4-6',
  system: SYSTEM_PROMPT_DEFAULT,
  skills: [
    { type:'anthropic', skill_id:'xlsx' },
    { type:'anthropic', skill_id:'docx' },
    { type:'anthropic', skill_id:'pdf' },
    { type:'anthropic', skill_id:'pptx' },
    { type:'custom', skill_id:'skill_011...', version:'latest' }
  ],
  mcp_servers: [
    { name:'figma', url:<our>/api/mcp/figma },
    { name:'veo',   url:<our>/api/mcp/veo },
  ],
  tools: [
    { type:'agent_toolset_20260401' },
    { type:'mcp_toolset', mcp_server_name:'figma',
      default_config:{enabled:true,
        permission_policy:{type:'always_allow'}} },
    { type:'mcp_toolset', mcp_server_name:'veo', ... },
  ],
  metadata: { tenant:'awc-lease', env:'poc',
              product:'managed-lease' },
}
scripts/bootstrap.ts
diff-update algorithm
1. existingId = env.ANTHROPIC_AGENT_ID__<KEY>

2. // 새 agent
   if !existingId → beta.agents.create(entry)

3. // 기존 agent — diff
   current = beta.agents.retrieve(existingId)

   compare 5 fields:
     · model          (string)
     · skills         (sorted by type/id)
     · mcp_servers    (sorted by name)
     · metadata       (every k:v)
     · system         (full string)

4. if any mismatch → beta.agents.update(existingId, {
     version: current.version,   ← Trap #15
     model, system, skills, mcp_servers,
     tools, metadata
   })  → version + 1

5. else: no-op (멱등 ✓)

+ syncDbAgentRow:
    agents 테이블 user_id NULL row upsert
+ admin user_quotas 자동 상향
    1M/10M (ADMIN_EMAILS 기반)
version bump의 의미

agent는 version pinning 가능. 새 session 생성 시 default = latest. Stage 4에서 user별 version 다르게 (A/B) 또는 stable pinning 검토.

신규 agent 추가
  1. agents-catalog.ts에 entry
  2. pnpm bootstrap
  3. Vercel env ANTHROPIC_AGENT_ID__<KEY>
  4. 배포
현 카탈로그
  • · default v20 — 모든 도구
  • · veo_studio v1 — veo 단독
  • 후보: research_pro · figma_inspector · korean_law · office_only
§6
Custom Skill Packaging

Progressive disclosure.
큰 reference, 적은 비용.

Custom Skill = Anthropic Files API에 hosted된 markdown bundle. SKILL.md만 항상 로드, references/는 trigger 시점에 lazy load. cache_creation 19K + cache_read 80K 패턴.

bundle 구조
8 files · 56KB
awc-landing-hero-veo3/          ← top-level wrapper 필수
├─ SKILL.md                ← 항상 로드 (~5KB)
│   └─ trigger 안내 + Phase 1~3
├─ references/                  ← lazy load
│   ├─ style-palette.md         (V2 9-style)
│   ├─ prompt-bp.md
│   └─ chained-workflow.md
├─ assets/
│   └─ image samples
└─ scripts/
    └─ helpers
runtime
progressive disclosure cache
1. agent.skills attach (catalog):
   { type:'custom', skill_id, version:'latest' }

2. 첫 trigger ("랜딩 hero 영상 만들어"):
   - SKILL.md inject
   - cache_creation += 19K   skill base

3. SKILL.md 안내 → V2 lazy load:
   - read('references/style-palette.md')
   - cache_read += 80K       90% 할인

4. tool 호출 (mcp__veo__veo_generate):
   - 반복 호출 시 cache hit (5분 TTL)

5. 종료 후 같은 session 재 trigger:
   - SKILL.md만으로 시작 (cache 90% hit)
   → 토큰 비용 + context window 둘 다 절약
INSIGHT 자체 skill bundle = 컨설팅 노하우의 잠금

Anthropic 공식 4종 skill (xlsx/docx/pdf/pptx)은 누구나 attach 가능. 자체 custom skill (landing-hero-veo3, 도메인별 skill)은 우리만의 bundle — 같은 base 모델에서 같은 prompt가 더 좋은 결과를 내는 AWC 차별화의 기술적 근거. Veo 검증으로 동등 퀄리티 (8s mp4 ~$3.20 / 편) 확인 완료.

Trap #21 top-level wrapper dir 필수

files=[{ path:'awc-landing-hero-veo3/SKILL.md', ...}] — wrapper 없이 SKILL.md 단독이면 400 "SKILL.md must be in top-level folder".

§7
Observability & Pricing

events.list = audit log.
span = billing anchor.

Anthropic이 events.list append-only 제공 (delete 전까지 무기한). 우리는 span.model_request_end를 anchor로 token 누적해서 user_quotas cap에 사용.

vendor cost formula
Sonnet 4.6 + 70% caching 가정
cost =
  (active_seconds / 3600) × $0.08
    // session-hour, running 상태만

+ input_tokens   × $3 / MTok
+ output_tokens  × $15 / MTok

+ cache_read   × base × 0.1
    // 90% 할인
+ cache_create × base × 1.25 (5m) | × 2 (1h)

+ web_searches × $0.01

⚠ idle/rescheduling/terminated = 0원
   30일 idle ≠ 720h × $0.08 (잘못된 가정)
시나리오별 vendor 비용
월 active 월 년
30 h$47$570
88 h (보통)$139$1,672
200 h$316$3,792
720 h (24/7)$1,138$13,656
audit · 기록되는 곳

우리 측 mirror

  • ·
    chat_messages — 우리 DB. RLS 본인 row만. anthropic_event_id UNIQUE
  • ·
    chat_sessions.usage — 누적 토큰 (input/output/cache_creation/cache_read)
  • ·
    Anthropic events.list — append-only, 무기한 (forensic용 archive 권장)
  • ·
    mcp_request_log — 자체 MCP wire 디버그 (alpha 한정)
quota · 누적 메커니즘

cap counter

span.model_request_end ev:
  ev.model_usage = {
    input/output/cache_creation/cache_read
  }
  ↓
chat_sessions.usage = prev + ev.model_usage
user_quotas.day_used + month_used += sum

period != today → lazy reset
day_used > daily_max → 429
일반 user
100K / 1M
admin (10×)
1M / 10M
RETENTION archive vs delete

Anthropic events는 delete 전까지 무기한 보존. PIPA·GDPR 대응 시 cron으로 직접 정리. 권장: 종료 N일 뒤 sessions.archive (event log 보존, secrets purged, read-only). 즉시 sessions.delete는 forensic 손실. 우리 측 chat_files는 매일 04:00 KST cron으로 7일 orphan 삭제.

부록

DB 14 테이블 · Stage · Trap 누계

L1~L3 핵심 4종
  • agents
  • agent_subscriptions
  • chat_sessions
  • chat_messages
사용자 격리 5종
  • vaults · memory_stores
  • user_connections
  • user_quotas · chat_files
운영 5종
  • environments · invites
  • mcp_user_tokens
  • mcp_request_log · veo_jobs
migrations: 19 · NON-NEGOTIABLE PR 경유
Stage 진척
  1. ✓
    Stage 1 — PoC
    Web chat MVP. AC 10/13.
  2. ✓
    Stage 2 — 멀티-유저 인프라
    vault·memory·status·env·skill·files·usage 8/8
  3. ✓
    Stage 3 — alpha 게이트
    Vercel · cap · invite · interrupt · cron · MCP
  4. ▶
    Stage 4 — 임대 상품화
    multi-agent + grants. 결제·plan·self-service 진행 중
학습 누계 — 24 trap

PoC 진행 중 직접 부딪힌 함정 24건. 핵심 4건 (#8 #15 #17 #21) 본문 inline.

Stage 4 다음
  • · 신규 카탈로그 4종
  • · Stripe 결제 + auto grant
  • · /admin/grants 폴리시
  • · self-service 임대 요청
  • · prod Supabase 분리
  • · mp4 외부 hosting (Trap #24)