Flue 스킬·MCP·서브에이전트·샌드박스는 컨텍스트에 들어오는 방식이 다르다

Flue로 만든 고객 응대 에이전트에 스킬, 서브에이전트, MCP 서버, 샌드박스를 차례로 붙여 보면 useSkill, useSubagent, useMcpConnection, useSandbox 모두 에이전트 함수에 한 줄을 더하는 같은 모양이고, 조건문 안에 넣으면 상태에 따라 붙였다 뗄 수도 있다. 하지만 모델이 호출마다 받는 분량과 실제 내용이 들어오는 경로는 넷이 모두 다르다. 무엇을 붙일지는 기능 목록보다 모델이 늘 보는 분량과 격리 정도를 보고 정한다

훅모델이 늘 보는 것내용이 들어오는 때일하는 곳
useSkill스킬마다 이름과 설명 한 줄activate_skill 도구 결과로 지침 전체지금 대화의 컨텍스트
useSubagent서브에이전트마다 이름과 설명 한 줄task 도구 결과로 자식의 최종 답새 컨텍스트의 자식 세션
useMcpConnection서버 도구 전부의 이름·설명·입력 스키마도구를 부를 때 그 결과원격 MCP 서버
useSandbox파일·셸 도구 6개와 작업 디렉터리 정보도구를 부를 때 그 결과가상 파일 시스템이나 컨테이너

둘째 열은 모델 호출마다 붙는 비용이고, 셋째 열은 실제로 대화에 쌓이는 내용이다. 별도 컨텍스트 창에서 일해 중간 과정을 부모 대화에 남기지 않는 것은 서브에이전트뿐이다.

스킬은 설명 한 줄로 선택되므로 설명이 정확해야 한다

스킬(skill)은 마크다운으로 쓴 작업 지침 묶음으로, 공개 형식인 Agent Skills를 따른다. 모델은 스킬 목록에서 이름과 설명 한 줄만 보고, 작업이 어떤 스킬과 맞는다고 판단하면 activate_skill 도구를 불러 지침 전체를 도구 결과로 받는다(Skills — How activation works). 시스템 프롬프트는 그대로이므로 스킬을 열어도 2편에서 다룬 프롬프트 캐시가 깨지지 않는다

예제의 caveman 스킬은 skills/caveman/SKILL.md에 둔 마크다운이다

---
name: caveman
description: Speak like a caveman, use this when the user asks for a caveman explanation.
---

Rewrite the answer using these rules:

1. Use very short sentences.

프런트매터의 name과 description이 목록에 올라가는 한 줄이고, 본문이 활성화될 때 열리는 지침이다. name은 디렉터리 이름과 같아야 하고(Skills — Frontmatter fields), Flue가 빌드할 때 이를 검사한다(Skills — Import and mount a skill). 두 번째 스킬인 pirate는 코드로 정의했다

import { defineSkill, useSkill } from '@flue/runtime';
import cavemanSkill from "../skills/caveman/SKILL.md";

const pirateSkill = defineSkill({
    name: "pirate",
    description: "Speak like a pirate, use this when the user asks for a caveman explanation.",
    instructions: `Explain the subject using nautical metaphors and occasional pirate expressions.`,
});

// 에이전트 함수 안에서
useSkill(cavemanSkill);
useSkill(pirateSkill);

원시인처럼 설명해 달라고 보내면 모델이 activate_skill로 caveman을 열고, 이 호출은 1편에서 본 dynamic-tool 파트로 화면에 나타난다

예제의 pirate 설명 끝에는 caveman 설명과 같은 문장(asks for a caveman explanation)이 붙어 있다. 2편에서 본, 설명을 복사해 둔 도구와 같은 문제다. 스킬을 열지 말지는 설명에 달려 있으므로(Skills — What is a skill?) 원시인 말투 요청에는 두 설명이 모두 들어맞아 엉뚱한 스킬이 열릴 수 있다고 본다. pirate의 설명은 Speak like a pirate. Use when the user asks for a pirate-style explanation.처럼 고쳐 둔다

강의는 마크다운 스킬 파일의 이름이 꼭 SKILL.md일 필요는 없다고 설명한다. 그때는 import한 값이 평범한 문자열이라 defineSkill의 instructions로 넘겨야 스킬이 된다(Skills — Inline skills with defineSkill). 한편 모든 답에 늘 적용할 말투라면 스킬로 둘 필요가 없다. 문서도 늘 필요한 내용은 지시문에 합치라고 권한다(Skills — How activation works)

서브에이전트는 부모 대화를 모르므로 위임 프롬프트에 필요한 내용을 다 담아야 한다

서브에이전트(subagent)는 부모 에이전트가 일을 맡기는 이름 붙은 대리인이다. 예제는 아이디어를 낙관적 관점과 회의적 관점에서 평가하는 두 서브에이전트에 사용자의 아이디어를 맡겨 의견을 모으게 했다. 회의적 평가를 맡는 쪽은 다음과 같다

import { defineSubagent } from "@flue/runtime";

function Skeptic() {
    return `Evaluate the idea from a skeptical perspective.
    Find the weakest assumption and the largest practical risk. Be critical but fair.`;
}

export const skepticSubAgent = defineSubagent({
    name: "skeptic_subagent",
    description: "Evaluates ideas from a skeptical perspective.",
    agent: Skeptic,
});

agent에는 함수를 호출하지 않고 넘긴다. 'use agent' 모듈에서 export한 대문자 함수는 독립 에이전트로 등록되므로, 그 모듈에 둔다면 이 함수를 export하지 않는다(Subagents — Declaring a subagent). 예제는 지시어가 없는 별도 모듈에 정의만 export했다. 부모 쪽은 다음과 같다

'use agent';
import { GeneralSubagent, useModel, useSubagent } from '@flue/runtime';
import { optimisticSubAgent, skepticSubAgent } from '../sub-agents';

export function CustomerSupport() {
    useModel("cloudflare/@cf/zai-org/glm-5.3");
    useSubagent(GeneralSubagent);
    useSubagent(optimisticSubAgent);
    useSubagent({
        ...skepticSubAgent,
        model: "cloudflare/@cf/deepseek-ai/deepseek-v4-pro-0813",
        thinkingLevel: "high",
    });
    return `You are an advisor agent, and you help users with ideas, and you analyze them with your team of advisors.`;
}

위임은 모델이 정한다. 선언한 서브에이전트는 시스템 프롬프트의 Available Agents 목록에 이름과 설명으로 실리고, 모델이 프레임워크의 task 도구에 이름과 프롬프트를 넘기면 다음과 같이 흘러간다(Subagents — How delegation works).

부모 대화 ── task("skeptic_subagent", "다음 아이디어를 평가하라: ...")
   ├─▶ 자식 세션: Skeptic() 렌더 → 자기 지침과 도구로 끝까지 작업
   │     (부모의 대화 기록·지시문·도구·스킬은 보이지 않는다)
   ▼
부모 대화에는 task 결과로 자식의 최종 메시지 1건만 들어온다

자식이 부모 대화에 관해 아는 것은 task 프롬프트가 전부다. 문서는 부모 지시문에 ‘완결된 프롬프트로 위임하라’는 문장을 넣으라고 권하는데, 예제의 부모 지시문에는 그 문장이 없어서, 실제로 쓴다면 이 문장부터 더한다. 모델이 두 task를 한 번에 내면 두 평가는 동시에 진행된다

자식은 샌드박스와 작업 공간 정보, 정의에서 덮어쓰지 않은 모델·추론 수준만 물려받고, 부모의 대화 기록·지시문·도구·스킬·영속 상태는 물려받지 않는다(Subagents — What a subagent inherits). 자식에게 도구나 스킬이 필요하면 서브에이전트 함수 안에서 useTool, useSkill로 붙인다. 모델·영속 상태·샌드박스·MCP·데이터 라이터·이벤트 훅은 그 안에서 선언하면 예외가 난다(Agent Hooks API — Rendering and the rules of hooks)

부모를 Flash 모델(glm-5.3-flash)로 두었을 때는 두 task 호출이 모두 실패했고, glm-5.3으로 바꾸자 위임이 끝까지 진행됐다. 원인은 작은 모델이 task 도구의 입력 형식을 지키지 못한 데 있다는 것이 강의의 설명이다. 이 변경은 자식에게도 번진다. optimistic과 GeneralSubagent(예약 이름 flue-general인 범용 대리인)는 마운트할 때 모델을 덮어쓰지 않았으므로 부모를 따라 glm-5.3을 쓰고, 마운트할 때 model·thinkingLevel을 덮어쓴 skeptic만 DeepSeek를 쓴다(Subagents — The general-purpose delegate). 부모 모델을 바꾸면 모델을 지정하지 않은 서브에이전트의 비용과 속도도 함께 바뀐다

MCP 서버는 도구 정의가 모두 실리므로 허용 목록으로 좁힌다

MCP(Model Context Protocol)는 AI 애플리케이션을 외부 시스템에 연결하는 공개 표준으로(MCP 소개), 서버에 연결하면 그 서버의 도구를 원격으로 쓴다. 예제는 인증 없이 쓰는 Cloudflare 문서 MCP 서버를 붙였다

import { defineMcpConnection, useMcpConnection } from '@flue/runtime';

const cloudflareMcpConnection = defineMcpConnection({
    name: "cloudflare_docs",
    url: "https://docs.mcp.cloudflare.com/mcp",
});

// 에이전트 함수 안에서
useMcpConnection(cloudflareMcpConnection);

에이전트가 메시지 처리를 시작할 때 Flue가 서버에 연결해 도구 목록을 받고, 그 도구를 mcp__<서버 이름>__<도구 이름> 형식으로 도구 집합에 넣는다(MCP — Connect to an MCP server). 서버가 무엇을 싣는지는 MCP의 tools/list 요청으로 볼 수 있다. 응답이 SSE의 data: 줄로 오므로 sed로 접두어를 뗐고, 2026년 11월 1일 기준 결과는 다음과 같다

curl -s https://docs.mcp.cloudflare.com/mcp \
  -H 'content-type: application/json' \
  -H 'accept: application/json, text/event-stream' \
  -H 'mcp-protocol-version: 2025-06-18' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \
  | sed -n 's/^data: //p' | jq -r '.result.tools[] | "\(.name): \(.description | split("\n")[0])"'
search_cloudflare_documentation: Search the Cloudflare documentation.
migrate_pages_to_workers_guide: ALWAYS read this guide before migrating Pages projects to Workers.

도구는 두 개이고 둘 다 mcp__cloudflare_docs__ 접두사가 붙어 들어온다. 출력은 설명의 첫 줄만 잘랐고, 검색 도구의 설명 전체는 검색 대상 제품 목록까지 담은 538자다. 이 설명과 입력 스키마가 모델 호출마다 도구 정의로 함께 간다. 문서 검색만 필요한 에이전트라면 tools: ["search_cloudflare_documentation"] 허용 목록으로 검색 도구만 붙인다. 허용 목록에 서버에 없는 이름을 적으면 연결이 실패한다(MCP — Specifying tools)

허용 목록은 비용 외에 보안에도 걸린다. 문서는 MCP 서버의 도구 설명이 프롬프트에, 도구 결과가 대화에 그대로 들어가므로 직접 관리하지 않는 서버를 서드파티 의존성처럼 다루라고 한다(MCP — Security). 위의 ALWAYS read this guide ...처럼 서버 운영자가 쓴 지시문이 모델 프롬프트에 들어오므로, 서버가 설명을 바꾸면 에이전트의 프롬프트도 함께 바뀐다

연결에 실패하면 그 제출은 모델을 부르기 전에 실패하고, optional: true를 주면 그 서버의 도구 없이 진행한 뒤 다음 제출에서 다시 연결한다(Agent API — McpConnectionDefinition). 연결은 인스턴스가 살아 있는 동안 재사용되므로(MCP — Connect to an MCP server), 켜지 않으면 서버가 응답하지 않는 동안 연결을 새로 맺어야 하는 메시지가 모델을 부르기 전에 실패한다. 문서 검색처럼 없어도 답할 수 있는 서버라면 optional을 켜 둔다

샌드박스에 쓴 파일이 남는 기간은 가상과 컨테이너가 다르다

샌드박스는 에이전트가 파일을 읽고 쓰고 명령을 실행하는 환경이다. useSandbox()를 붙이면 도구 집합에 read, write, edit, bash, grep, glob 여섯 개가 추가되고, 초기화할 때 작업 디렉터리 경로와 디렉터리 목록, AGENTS.md 내용이 시스템 프롬프트에 들어간다. 문서는 작업에 필요한 범위에서 가장 좁은 환경을 고르라고 권한다. 환경이 넓을수록 모델의 지시로 읽고 바꾸고 실행할 수 있는 범위도 넓어지기 때문이다(Sandboxes — What a sandbox adds)

가장 가벼운 것은 가상 샌드박스다. just-bash는 TypeScript로 구현한 bash 에뮬레이터로, 실제 프로세스를 띄우지 않고 메모리 위의 파일 시스템에서 ls, sed, awk, jq 같은 명령을 흉내 낸다. 파일 두 개를 넣은 가상 샌드박스는 다음처럼 만들 수 있다

import { bash, useSandbox } from '@flue/runtime';
import { Bash, InMemoryFs } from 'just-bash';

const files = { '/potatoes/brief.md': '# Q3 brief', '/potatoes/sales.csv': 'month,amount' };

// 에이전트 함수 안에서
useSandbox(bash(() => new Bash({ fs: new InMemoryFs(files), cwd: '/potatoes' })));

bash(...)에 넘긴 함수는 렌더마다 불리지 않고 에이전트를 초기화할 때 불리며, 모델의 bash 명령은 cwd인 /potatoes에서 시작한다

강의는 이 샌드박스를 서버를 끄면 처음 파일로 돌아가는 테스트용 환경으로 소개하는데, 문서가 적는 주기는 더 짧다. 가상 샌드박스는 새 작업마다 다시 만들어지므로 한 메시지에서 쓴 파일은 다음 메시지에서 사라진다(Sandboxes — The virtual sandbox). 메시지를 넘어 남길 값은 영속 상태에 두고, 파일 자체가 남아야 하면 원격 샌드박스를 쓴다. 다만 원격 샌드박스도 컨테이너가 쉬면 파일이 지워질 수 있다

Cloudflare 타깃에서는 Cloudflare Sandbox가 대화마다 리눅스 컨테이너를 붙인다. npx flue add sandbox cloudflare --print | claude처럼 블루프린트(구현 가이드)를 코딩 에이전트에 넘기면(flue add) 패키지 설치, Sandbox 클래스를 내보내는 cloudflare.ts, wrangler.jsonc 설정, Dockerfile 작성까지 적용된다(Cloudflare Sandbox — Overview)

"migrations": [
  { "tag": "v1", "new_sqlite_classes": ["FlueCustomerSupportAgent"] },
  { "tag": "v2", "new_sqlite_classes": ["Sandbox"] },
],
"durable_objects": { "bindings": [{ "class_name": "Sandbox", "name": "Sandbox" }] },
"containers": [{ "class_name": "Sandbox", "image": "./Dockerfile" }],

v1을 두고 v2를 덧붙인 것은 1편에서 본 마이그레이션 규칙대로다. Dockerfile은 FROM docker.io/cloudflare/sandbox:0.12.14 한 줄이고, 이미지 태그는 설치한 패키지 버전과 맞춘다(Deploy to Cloudflare — Connecting a remote sandbox). 아래 수명과 이미지 설명도 예제가 쓴 0.x 문서 기준이고, npm 최신인 1.x에서도 맞는지는 확인하지 않았다. 에이전트는 다음과 같이 붙인다

'use agent';
import { env } from "cloudflare:workers";
import { getSandbox, type Sandbox } from "@cloudflare/sandbox";
import { type AgentProps, useModel, useSandbox } from '@flue/runtime';
import { cloudflareSandbox } from "@flue/runtime/cloudflare";

interface Env {
    Sandbox: DurableObjectNamespace<Sandbox>;
}

export function CustomerSupport({ id }: AgentProps) {
    useModel("cloudflare/@cf/zai-org/glm-5.3");
    const { Sandbox: sandboxNamespace } = env as unknown as Env;
    useSandbox(cloudflareSandbox(getSandbox(sandboxNamespace, id)));
    return `You are a helper agent that helps the user with files and accounting`;
}

{ id }는 1편에서 대화 URL의 마지막 세그먼트로 정한 대화 ID다(Agent API — AgentProps). 이 값을 getSandbox에 넘기므로 대화마다 전용 샌드박스가 붙고, 같은 대화의 다음 메시지는 같은 샌드박스로 간다. 작업 디렉터리는 따로 정하지 않으면 컨테이너의 /workspace다. wrangler types가 만든 Env에는 Sandbox 바인딩이 제네릭 없이 들어 있어 getSandbox의 인자 타입과 맞지 않으므로, 제네릭을 붙인 Env를 직접 선언하고 env를 그 타입으로 읽었다

기본 이미지는 Node.js, Bun, git, curl을 갖춘 Ubuntu 이미지이고 Python은 들어 있지 않아서, Python 버전을 물으면 command not found가 나온다(Dockerfile reference — Default image). Python을 설치해 문자열을 대문자로 바꾸는 함수를 만들어 달라고 하면 에이전트가 apt-get으로 설치하고 파일을 써서 직접 실행한다

설치한 Python과 파일이 언제까지 남는지는 문서마다 서술이 다르다. Flue 배포 문서는 세션마다 영속 파일 시스템을 갖춘 컨테이너가 붙는다고 소개하고(Deploy to Cloudflare — Connecting a remote sandbox), Sandbox SDK 0.x 문서는 컨테이너가 활동하는 동안에만 상태가 남는다고 적는다(Sandbox lifecycle — Container lifetime and state). 컨테이너 수명은 Cloudflare가 정하므로 여기서는 Cloudflare 문서를 따랐다. 요청 없이 10분이 지나면(sleepAfter로 조정) 컨테이너가 멈추고, 다음 요청은 처음 상태의 새 컨테이너로 시작한다(Sandbox lifecycle — Idle). 10분 뒤 작업 공간이 비는지는 직접 재 보지 않고 문서로만 확인했다. 대화 기록은 Durable Object의 SQLite에 남으므로 다음 날 같은 대화로 돌아와도 대화는 이어지지만, 작업 공간과 설치한 Python은 처음 상태다. 컨테이너 수명을 넘겨 파일을 남기려면 R2 버킷 마운트나 백업·복원을 붙인다(Mount buckets)

늘 쓰는 도구라면 매번 설치하게 두지 말고 이미지에 넣는다. Python은 0.12.14-python 변형 이미지에 Python 3.11과 pip, 데이터 분석 패키지가 미리 들어 있다(Dockerfile reference — Python image). 컨테이너는 Workers 유료 플랜에서만 쓸 수 있고(Containers — Pricing), 로컬에서는 Docker 호환 엔진이 필요하다(Containers — Local development)

조건부로 붙였다 떼도 캐시가 남는 것은 스킬과 서브에이전트다

2편에서 본 조건부 도구처럼 네 훅 모두 조건부로 선언할 수 있다. 영속 상태의 사용자 등급을 보고 VIP에게만 스킬과 MCP 서버를 붙인다면 다음처럼 쓸 수 있다

const [tier] = usePersistentState<"basic" | "vip">("tier", "basic");

useSkill(cavemanSkill);
if (tier === "vip") {
    useSkill(pirateSkill);
    useMcpConnection(cloudflareMcpConnection);
}

caveman을 조건 밖에 둔 것은 프레임워크가 스킬이 하나라도 있을 때만 activate_skill 도구를 넣기 때문이다(Tools — Built-in tools). 스킬이 없던 에이전트에 첫 스킬이 붙으면 이 도구가 새로 생기지만, 늘 붙어 있는 스킬이 하나 있으면 VIP 스킬이 오가도 도구 집합은 그대로다. 바뀐 선언이 반영되는 때와 프롬프트 캐시에 미치는 영향은 훅마다 다르다

훅바뀐 선언이 반영되는 때프롬프트 캐시
useSkill다음 턴 경계유지된다
useSubagent다음 턴 경계유지된다
useMcpConnection다음 제출깨진다고 본다
useSandbox다음 턴 경계깨진다

턴 경계는 2편에서 본 렌더 시점, 곧 다음 모델 호출 직전이다. 스킬 목록과 서브에이전트 목록은 시스템 프롬프트에 처음 실린 상태로 고정되고(컴팩션 때 다시 잡힌다), 바뀐 부분은 대화에 resources 신호로 덧붙는다. task 도구의 정의도 늘 같아서 둘을 켜고 꺼도 캐시가 남는다(Agent API — Dynamic resources). 샌드박스를 붙이거나 떼면 파일·셸 도구가 함께 오가므로 캐시가 깨진다(Sandboxes — Conditional attachment). MCP 연결은 모델 설정처럼 제출 단위로 읽혀 다음 메시지부터 붙거나 빠진다(Agent Hooks API — useMcpConnection). 이때의 캐시는 문서에 직접 적혀 있지 않다. 다만 MCP 도구도 도구 집합에 들어가고, 직접 선언한 도구 목록이 바뀌면 모델 제공자에게 보내는 도구 배열이 달라져 캐시가 깨진다고 문서가 적으므로(Agent API — Dynamic resources) MCP 도구가 오갈 때도 깨진다고 본다

대화 도중 자주 바뀌는 조건에는 스킬과 서브에이전트만 걸고, MCP 서버와 샌드박스는 조건 없이 두거나 VIP 등급처럼 드물게 바뀌는 값에만 거는 것이 캐시를 지키는 방법이다


이 시리즈의 글: Flue로 AI 에이전트 만들기

  1. Flue 에이전트는 시스템 프롬프트를 반환하는 함수다
  2. Flue 에이전트 훅의 반영 시점은 모델과 프롬프트가 다르다
  3. Flue durable 도구만 크래시 후 재실행된다
  4. Flue 스킬·MCP·서브에이전트·샌드박스는 컨텍스트에 들어오는 방식이 다르다 (이 글)
  5. Flue 대화 스트림은 offset으로 이어 받는다

출처와 범위

노마드 코더 AX 스쿨의 Flue 강의에서 출발했다. Flue와 Cloudflare 문서는 2026년 11월 1일 기준이고, 문서 대조는 AI(Claude)와 함께 했다.

참고 자료