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_subscriptionsstatus='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 /interruptuser.interrupt event 별도 호출 필수.

Trap #8 tool_use_id 매핑 룰

agent.tool_use자기 id(ev.id)가 agent.tool_resultev.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)