Flue로 첫 에이전트를 만들면 손이 가는 곳은 에이전트 코드보다 그 바깥이다. 에이전트는 시스템 프롬프트를 반환하는 함수 몇 줄로 끝나고, 대화 기록과 스트리밍, 중단된 작업의 복구는 Flue가 맡는다. 작성자에게 남는 일은 어디에 배포할지, 어떤 URL로 열지, 그 URL을 누구에게 열지를 정하는 것이다. 이 가운데 마지막이 가장 쉽게 빠진다. 토큰으로 호출자를 확인하더라도, 대화 ID마다 접근 권한을 따지는 검사를 더하지 않으면 같은 토큰을 가진 사용자가 남의 대화를 읽을 수 있다
에이전트 코드는 몇 줄이고 실행 루프는 하네스가 맡는다
하네스(harness)는 모델을 감싸 실제로 일을 하게 만드는 실행 루프다. 모델 응답에서 도구 호출을 꺼내 실행하고, 결과를 다시 모델에 넣고, 컨텍스트가 넘치면 요약한다. 강의는 모델을 뇌, 하네스를 몸에 비유하며, 같은 모델이라도 하네스에 따라 결과가 달라진다고 설명한다
Flue는 OpenClaw를 구동하는 오픈 하네스 Pi를 기반으로 한다. Flue 문서는 하네스를 프레임워크의 부가 기능으로 두지 않고 핵심으로 둔다고 설명한다(Why Flue?). Flue를 만든 곳은 Astro 팀이고, Cloudflare 블로그는 Flue를 하네스(Pi)와 런타임(Cloudflare Agents SDK) 사이의 프레임워크 층으로 소개한다(Cloudflare 블로그)
[작성자 코드] 에이전트 함수 + 훅 [Flue] 프로젝트 구조, 라우팅, 영속성, React/SDK 클라이언트 [Pi] 에이전트 루프: 도구 호출, 컨텍스트 관리, 컴팩션 [런타임] Node.js 프로세스 또는 Cloudflare Durable Object
작성자가 직접 쓰는 것은 맨 위 한 층이고, 나머지 세 층은 별도 설정 없이 따라온다. 하네스를 직접 만들지 않아도 된다는 점이 Flue를 고를 가장 큰 이유라고 본다. 그 대신 대화가 언제 다시 실행되고 어디까지 복구되는지는 하네스와 런타임의 규칙을 따르므로, 그 규칙은 알고 써야 한다. 이 시리즈의 뒤 편들이 그 규칙을 다룬다
Cloudflare 타깃에서는 API 키와 저장소 설정이 필요 없다
Flue는 같은 에이전트 코드를 Node.js 서버와 Cloudflare 등 여러 곳에 배포할 수 있다. 강의가 Cloudflare를 고르는 이유는 두 가지다. 하나는 Workers AI다. Cloudflare 계정에 바인딩만 걸면 GLM, Nemotron 같은 여러 회사의 모델을 제공사별 API 키 없이 호출한다. 다른 하나는 비용이다. Workers 무료 플랜은 하루 10만 요청, 유료 플랜은 월 최소 5달러에 월 1,000만 요청을 포함한다(Workers 요금)
모델을 부를 때는 접두사가 인증 방식을 정한다. 같은 Workers AI 모델이라도 접두사에 따라 키가 필요한지가 갈린다
| 접두사 | 쓰는 곳 | 인증 |
|---|---|---|
cloudflare/ | Cloudflare 타깃에서만 자동 등록 | Worker의 AI 바인딩. API 키 없음 |
cloudflare-workers-ai/ | 모든 타깃 | CLOUDFLARE_API_KEY |
cloudflare/를 붙이면 Worker의 바인딩으로 호출되므로 키를 관리할 일이 없다(Models — Cloudflare Workers AI). 처음 생성된 프로젝트는 Node 타깃에 cloudflare-workers-ai/ 접두사로 되어 있어서, flue.config.ts의 타깃을 Cloudflare로 바꾸고 접두사도 cloudflare/로 고쳤다. Cloudflare에 배포할 계획이라면 처음부터 두 가지를 맞춰 두는 편이 낫다. cloudflare/ 제공자는 Cloudflare 타깃에서만 등록되므로, 로컬에서 시험할 때도 npm run dev로 Worker 개발 서버를 띄워서 한다
export default defineConfig({
target: 'cloudflare',
});
저장소 쪽은 Durable Object(DO)가 맡는다. DO는 Cloudflare가 제공하는 상태 있는 Worker로, 객체마다 자기 SQLite 저장소를 가진다. Cloudflare 타깃에서 Flue는 대화와 첨부 파일, 제출 기록을 그 대화를 소유한 DO의 SQLite에 자동으로 저장한다. 그래서 Node 타깃의 저장소 어댑터인 src/db.ts는 필요 없고, 소스 루트에 남아 있으면 빌드가 거부된다(Cloudflare target — Generated Durable Objects)
Workers AI와 DO는 wrangler.jsonc에서 연결한다
{
"name": "flue-backend",
"compatibility_date": "2026-06-01",
"compatibility_flags": ["nodejs_compat"],
"observability": { "traces": { "enabled": true } },
"ai": { "binding": "AI" },
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["FlueHelloAgent"] }],
}
ai 바인딩은 이 Worker가 Workers AI를 쓰겠다는 선언이고, 이 한 줄이면 키 없이 모델을 부른다. migrations는 Flue가 에이전트마다 만드는 DO 클래스를 Cloudflare에 등록한다. Hello 함수는 FlueHelloAgent 클래스가 된다. 이 마이그레이션 목록은 배포 이력이라 Flue가 대신 써 주지 않고 작성자가 직접 관리한다(Cloudflare target — Managing migrations)
함수 이름은 대화 저장소의 키라서 바꿀 때 조심한다
프로젝트가 처음 만들어 주는 src/agents/hello.ts의 에이전트는 다음과 같다
'use agent';
import { useModel } from '@flue/runtime';
export function Hello() {
useModel('cloudflare/@cf/zai-org/glm-5.3-flash');
return 'You are a helpful assistant. Keep replies short.';
}
이 몇 줄이 에이전트 전체이고, 규칙은 세 가지다(Agents — “use agent” Directive)
- 파일 맨 위, import보다 앞에
'use agent'지시어를 둔다. 빌드할 때 Flue가 이 지시어가 있는 파일을 찾는다 - 그 파일에서 export한 대문자 함수가 각각 에이전트로 등록된다. 한 파일에 여러 개를 둘 수 있다
- 함수가 반환하는 문자열이 시스템 프롬프트다. 반환하는 것은 문자열 하나이고, 메시지를 받거나 모델을 호출하는 코드는 쓰지 않는다
함수 이름에는 의미가 하나 더 있다. 문서에 따르면 export한 함수의 이름이 에이전트의 영속 정체성(durable identity)이 되어 대화 저장소의 키로 쓰인다(Agents). Cloudflare에서는 DO 클래스 이름도 여기서 나온다. 그래서 Hello를 CustomerSupport로 바꾸면 wrangler.jsonc의 클래스 이름도 함께 바꿔야 한다
Hello를 CustomerSupport로 바꿀 때는 v1 항목의 클래스 이름을 그 자리에서 고쳤다. 한 번도 배포하지 않은 상태라 문제가 없었다. 배포한 뒤라면 사정이 다르다. 배포된 마이그레이션 항목은 고치거나 순서를 바꾸지 않고, 새 태그에 renamed_classes를 덧붙여야 저장된 대화가 새 이름으로 넘어간다
// 배포 후에 이름을 바꾸는 경우
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["FlueHelloAgent"] },
{ "tag": "v2", "renamed_classes": [{ "from": "FlueHelloAgent", "to": "FlueCustomerSupportAgent" }] },
]
v1은 그대로 두고 v2에서 이름 변경을 선언한다. 이름을 바꿀 여지가 조금이라도 있다면 처음부터 CustomerSupport.agentName = 'customer-support'처럼 정체성을 고정해 두겠다. 그러면 함수 이름을 바꿔도 마이그레이션이 필요 없다. 다만 agentName 값 자체를 바꾸면 정체성이 바뀌므로 그때는 다시 renamed_classes가 필요하다
에이전트 함수는 한 번 실행되고 끝나는 설정 코드도 아니다. Flue는 모델을 호출하기 직전마다 이 함수를 다시 실행해 시스템 프롬프트와 훅을 새로 만든다. 조건부 도구나 상태에 따른 모델 전환이 가능한 것도 이 성질 덕분이고, 자세한 반영 시점은 2편 “Flue 에이전트 훅의 반영 시점은 모델과 프롬프트가 다르다”에서 다룬다
에이전트를 HTTP로 열지는 app.ts에서 정한다
에이전트를 등록했다고 URL이 생기지는 않는다. HTTP로 열려면 src/app.ts에 직접 마운트한다
import { createAgentRouter } from '@flue/runtime/routing';
import { Hono } from 'hono';
import { Hello } from './agents/hello.ts';
const app = new Hono();
app.route('/hello/world', createAgentRouter(Hello));
export default app;
app.ts의 기본 export가 서버이고, Cloudflare에서는 이것이 Worker의 fetch 핸들러가 된다. Hono는 Workers에서 잘 도는 작은 웹 프레임워크로, 여기서는 라우팅만 맡는다. createAgentRouter(Hello)는 에이전트 하나의 HTTP 표면을 만드는 서브 라우터이고, 마운트 경로는 마음대로 정할 수 있다. 마운트 경로는 에이전트의 정체성과 무관하므로, 경로를 옮겨도 저장된 대화는 그대로다(Routing — Mounting an agent). 같은 폴더의 cloudflare.ts에는 큐 소비자나 크론 같은 Worker 수준 코드를 둔다
마운트 경로 뒤에 붙는 마지막 세그먼트가 대화 ID다. 라우터는 대화마다 다음 경로를 연다
| 요청 | 용도 |
|---|---|
POST /:id | 메시지 한 건 전달. 202로 즉시 응답 |
GET /:id | 대화 읽기(스냅샷, 변경분, 실시간 스트림) |
HEAD /:id | 스트림 메타데이터만 헤더로 받기 |
POST /:id/abort | 진행 중·대기 중 작업 중단 |
GET /:id/attachments/:attachmentId | 첨부 파일 내려받기 |
대화 ID는 호출하는 쪽이 정한다. 사용자 ID든 티켓 번호든 문자열이면 되고, 그 ID로 첫 메시지가 들어올 때 대화가 생긴다. 에이전트 하나가 ID만 바꿔 여러 사용자를 따로 응대하는 구조다
curl로 메시지를 보내고 기록을 읽는 명령이다
# 메시지 보내기: 응답은 202 접수 영수증
curl -X POST http://localhost:5173/hello/world/my-first-chat \
-H 'content-type: application/json' \
-d '{"kind":"user","body":"Tell me a joke."}'
# 대화 기록 읽기
curl "http://localhost:5173/hello/world/my-first-chat?view=history" | jq
첫 요청은 모델의 답을 기다리지 않는다. 메시지가 저장되는 순간 202와 함께 스트림 URL과 위치 정보를 돌려주고, 답은 같은 URL을 GET해서 읽는다. 개발 서버를 껐다 켜도 기록은 그대로 남고, my-first-chat 대신 user1로 보내면 별개의 대화가 생긴다. 데이터베이스 설정을 한 줄도 하지 않았는데도 그렇다. 스트림을 이어 받는 방법은 5편 “Flue 대화 스트림은 offset으로 이어 받는다”에서 다룬다
호출자가 대화 ID를 정한다는 점은 편하지만, 그 ID를 아는 사람이면 누구든 그 대화에 들어올 수 있다는 뜻이기도 하다. 이 점은 아래 접근 제어로 이어진다
React 화면은 대화 하나를 구독하고 파트 단위로 그린다
프런트엔드는 npx create-cloudflare@latest로 만든 React + Vite 프로젝트에 @flue/react와 @flue/sdk를 설치해 만든다. 화면을 그리는 핵심 코드는 다음과 같다
const { sendMessage, messages, status } = useFlueAgent({
url: "http://localhost:5173/hello/world/user-1",
});
const isThinking = status === "streaming" || status === "submitted";
{messages.map((message) => (
<li key={message.id}>
{message.parts.map((part) => {
if (part.type === "reasoning") return <em>{part.text}</em>;
if (part.type === "text") return <span>{part.text}</span>;
})}
</li>
))}
useFlueAgent는 URL 하나, 곧 대화 하나를 관찰한다. 기존 기록을 스냅숏으로 불러온 뒤 실시간 변경을 SSE로 이어 받으므로, 스트리밍 코드를 따로 쓰지 않아도 답이 조각 단위로 화면에 들어온다(React). sendMessage()는 생성이 끝날 때가 아니라 서버가 메시지를 접수할 때 끝나므로, 진행 표시는 status로 한다
메시지는 parts 배열을 가진다. 한 응답 안에 모델의 추론, 본문, 도구 호출이 섞여 오기 때문이다
part.type | 내용 |
|---|---|
text | 모델이 쓴 본문 |
reasoning | 모델의 추론 과정(지원 모델만) |
dynamic-tool | 도구 호출. 입력·출력·진행 상태 포함 |
file | 첨부 파일 |
화면이 서버 응답을 문자열 하나로 받지 않고 파트 타입별로 나눠 그리므로, 도구 호출 카드나 진행 표시 같은 UI를 텍스트 파싱 없이 만들 수 있다. 서버가 보내는 구조화 데이터 파트는 2편에서 다룬다
토큰 검사 위에 대화 ID 단위의 인가를 더한다
마운트한 에이전트에는 인증이 없다. 문서는 대화 URL에 닿을 수 있는 사람이면 누구나 그 대화와 이야기할 수 있다고 적는다. 메시지 전송뿐 아니라 기록 전체를 읽고 작업을 중단시키는 것도 마찬가지다(Routing — Protecting your agents)
클라이언트 쪽에서는 createFlueClient로 토큰을 넣는다
const client = useMemo(() => createFlueClient({
url: "http://localhost:5173/agents/user-1",
token: "TRUSTME"
}), []);
const { sendMessage, messages, status } = useFlueAgent({ client });
token은 모든 요청에 authorization: Bearer <token> 헤더로 실린다(createFlueClient). useMemo는 빼면 안 된다. 빼면 렌더링마다 새 클라이언트가 만들어지면서 “Maximum update depth” 오류가 난다. 문서도 새 클라이언트 인스턴스가 세션을 갈아 끼운다고 적고, 클라이언트를 메모이즈하라고 한다. 예제처럼 의존성 배열을 비워 두는데, 대화 ID나 토큰이 바뀌는 앱이라면 문서처럼 둘을 의존성 배열에 넣어 바뀔 때만 새로 만든다(React — Authentication and custom clients)
서버 쪽에서는 Hono 미들웨어로 헤더를 검사한다
app.use("/agents/*", async (context, next) => {
const auth = context.req.header("Authorization");
if (!auth?.includes("TRUSTME")) {
return context.json({error: "Not authenticated"}, 401);
}
await next();
})
app.route("/agents", createAgentRouter(Hello));
미들웨어는 마운트보다 먼저 선언해야 그 경로의 요청에 적용된다. /agents/* 패턴이므로 전송, 읽기, 중단, 첨부 다운로드가 모두 걸린다. Hono 미들웨어는 응답을 반환하거나 await next()를 불러야 하고, 둘 다 하지 않으면 요청이 다음 단계로 넘어가지 못해 오류가 난다
예제의 고정 문자열 대신 세션이나 JWT를 검증하고, 그 위에 인가를 더한다. 대화 ID는 호출자가 정하는 경로 세그먼트이므로, 소유권을 확인하지 않으면 로그인한 사용자 누구나 남의 대화 ID를 추측해 그 기록을 읽을 수 있다. 문서가 운영 환경에 요구하는 검사는 두 가지다
- 인증: 호출자가 누구인가
- 인가: 이 호출자가 이 대화 ID에 접근해도 되는가
문서의 패턴을 위 마운트 경로에 맞춰 옮기면 다음과 같다
app.use('/agents/*', async (c, next) => {
const user = await verifySession(c.req.raw); // 애플리케이션의 인증
if (!user) return c.json({ error: 'unauthorized' }, 401);
// 마운트 경로 바로 뒤 첫 세그먼트가 대화 ID다
const [conversationId] = c.req.path.slice('/agents/'.length).split('/');
if (!(await canAccessConversation(user, conversationId))) {
return c.json({ error: 'forbidden' }, 403);
}
return next();
});
app.route('/agents', createAgentRouter(CustomerSupport));
verifySession과 canAccessConversation은 애플리케이션이 구현할 함수다. 문서는 대화 ID를 서버가 발급하고 인증된 사용자에게서 끌어내는 방법도 권하는데, 그러면 인가 검사는 “경로의 ID와 세션의 사용자 ID가 같은가”라는 비교 하나로 줄어든다. 공개 URL로 운영할 에이전트라면 이 인가 검사부터 붙이겠다
에이전트 코드는 함수 몇 줄이지만, 그 함수를 어디에 배포하고 어떤 경로로 열고 누구에게 허락할지는 작성자가 정한다. 처음 에이전트를 열 때는 배포 타깃과 모델 접두사, 마운트 경로, 대화 ID 단위의 접근 검사를 차례로 확인하면 된다
이 시리즈의 글: Flue로 AI 에이전트 만들기
- Flue 에이전트는 시스템 프롬프트를 반환하는 함수다 (이 글)
- Flue 에이전트 훅의 반영 시점은 모델과 프롬프트가 다르다
- Flue durable 도구만 크래시 후 재실행된다
- Flue 스킬·MCP·서브에이전트·샌드박스는 컨텍스트에 들어오는 방식이 다르다
- Flue 대화 스트림은 offset으로 이어 받는다
출처와 범위
노마드 코더 AX 스쿨의 Flue 강의를 바탕으로 했다. 공식 문서와의 대조는 AI(Claude)와 함께 했고, 문서는 2026년 11월 1일 기준이다
참고 자료
- Why Flue? — Flue Docs
- Agents — Flue Docs
- Models — Flue Docs
- Cloudflare target — Flue Docs
- Routing — Flue Docs
- React — Flue Docs
- createFlueClient — Flue Docs
- Workers 요금 — Cloudflare Docs
- Cloudflare 블로그 — Flue
- Pi
- Hono