이 문서는 왜 그렇게 설계했는지 설명한다. 4계층 owner 분리, 메시지 한 건의 wire-level 흐름, closed-beta MCP 우회의 정공법.
Managed Agents의 핵심 설계는 "무엇이 어디에서 owner인가"의 분리. 위 2계층은 우리 DB가, 아래 2계층은 Anthropic이 소유.
1 row = 1 service template. agents (user_id NULL) — system / skills / mcp_servers / model / metadata.
Anthropic agent_id (versioned)와 1:1 매핑.
user × agent 매트릭스. agent_subscriptions의 status='active'가 없으면 session 생성 자체 차단.
신규 가입 시 트리거가 default 자동 grant.
chat_sessions +
vaults (1:1 user) +
memory_stores (1:1 user) +
environments (dev/prod).
session 생성 시 vault_ids + resources 주입 → Anthropic이 OAuth refresh + memory mount 자동.
gVisor sandbox per-session · /mnt/session/uploads/<file_id> · /mnt/memory/<store-name>/.
billing: $0.08 × active_seconds/3600 + tokens × model rate.
L1 catalog는 거의 변하지 않음. L4 container는 매번 새로 생성. 위로 갈수록 stable, 아래로 갈수록 ephemeral.
우리는 누가 무엇을 빌렸는가를 안다. Anthropic은 그걸 어떻게 안전히 실행하는가를 안다. 책임 분리가 무너지면 multi-tenant도 무너짐.
우리 row와 Anthropic session이 1:1로 짝지어진 유일한 계층. session_id가 wire의 모든 곳에서 식별자로 쓰임.
Anthropic 격리는 컨테이너 단위. 그러나 workspace API key 1개로 모든 customer를 호출하므로 RBAC는 우리 책임. 두 격리가 합쳐져야 진짜 multi-tenant.
per-session 컨테이너. 9p filesystem. 세션끼리 file system state 공유 X.
모든 outbound는 proxy 경유. JWT에 allowed_hosts + session_id 박힘. 169.254.169.254 metadata 차단.
CN=sandbox-egress-production CA로 outbound 본문 decrypted 검사.
credential은 write-only. "tokens are never reachable from the sandbox" — vault에서 매칭된 token만 outbound 시 자동 inject.
supabase.auth.getUser() → 401 if missing
chat_sessions.user_id === auth.uid → 403. RLS도 보조하지만 명시적 검증.
session 생성 시 active subscription 필수. 미임대면 throw AGENT_NOT_LEASED → 403.
일반 100K/1M, admin 1M/10M tokens. 초과 시 429 + 한국어 안내.
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 |
| 데이터 retention | events.list 무기한 / container 30d | ✓ archive·delete cron |
| PIPA·GDPR 동의 | ✗ | ✓ 약관 응대 |
AI SDK UI message stream wire (v1)을 따라 start → text-delta* → tool-* → finish 시퀀스 보장.
그 사이 Anthropic typed event를 우리 wire format으로 변환.
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
UNIQUE on anthropic_event_id → 재실행 시 23505 무시. vault/memory ensure 헬퍼도 race tolerant. bootstrap diff-update로 멱등.
idle ←──┐
│ │
↓ │
running ──┤ (msg)
├→ rescheduled (transient)
└→ terminated (irreversible)
useChat.stop()은 클라이언트 fetch만 끊음 → 서버 agent 계속 = 비용 누수. POST /interrupt로 user.interrupt event 별도 호출 필수.
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 별도.
Figma 공식 MCP는 closed beta — third-party OAuth/PAT 거부.
그래서 사용자 OAuth token은 우리가 보관, /api/mcp/figma를 우리가 직접 호스트.
Anthropic은 우리 server만 안다 — figma OAuth token은 우리만 안다.
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
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) }] }
initialize — protocolVersion 2025-06-18tools/list — 6 (figma) / 4 (veo)tools/call — bearer 검증 → executemcp_server_urlarchive 후 신규 create
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' } 동시 추가.
카탈로그 entry는 scripts/agents-catalog.ts에 코드로.
bootstrap이 retrieve → diff → update만 발생 시 version bump.
{
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' },
}
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 기반)
agent는 version pinning 가능. 새 session 생성 시 default = latest. Stage 4에서 user별 version 다르게 (A/B) 또는 stable pinning 검토.
pnpm bootstrapANTHROPIC_AGENT_ID__<KEY>default v20 — 모든 도구veo_studio v1 — veo 단독Custom Skill = Anthropic Files API에 hosted된 markdown bundle. SKILL.md만 항상 로드, references/는 trigger 시점에 lazy load. cache_creation 19K + cache_read 80K 패턴.
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
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 둘 다 절약
Anthropic 공식 4종 skill (xlsx/docx/pdf/pptx)은 누구나 attach 가능. 자체 custom skill (landing-hero-veo3, 도메인별 skill)은 우리만의 bundle — 같은 base 모델에서 같은 prompt가 더 좋은 결과를 내는 AWC 차별화의 기술적 근거. Veo 검증으로 동등 퀄리티 (8s mp4 ~$3.20 / 편) 확인 완료.
files=[{ path:'awc-landing-hero-veo3/SKILL.md', ...}] — wrapper 없이 SKILL.md 단독이면 400 "SKILL.md must be in top-level folder".
Anthropic이 events.list append-only 제공 (delete 전까지 무기한).
우리는 span.model_request_end를 anchor로 token 누적해서 user_quotas cap에 사용.
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 (잘못된 가정)
| 월 active | 월 | 년 |
|---|---|---|
| 30 h | $47 | $570 |
| 88 h (보통) | $139 | $1,672 |
| 200 h | $316 | $3,792 |
| 720 h (24/7) | $1,138 | $13,656 |
chat_messages — 우리 DB. RLS 본인 row만. anthropic_event_id UNIQUEchat_sessions.usage — 누적 토큰 (input/output/cache_creation/cache_read)events.list — append-only, 무기한 (forensic용 archive 권장)mcp_request_log — 자체 MCP wire 디버그 (alpha 한정)
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
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 삭제.
agentsagent_subscriptionschat_sessionschat_messagesvaults · memory_storesuser_connectionsuser_quotas · chat_filesenvironments · invitesmcp_user_tokensmcp_request_log · veo_jobsPoC 진행 중 직접 부딪힌 함정 24건. 핵심 4건 (#8 #15 #17 #21) 본문 inline.