안드로이드나 Flutter 앱처럼 JavaScript SDK를 쓸 수 없는 클라이언트에서 Flue 에이전트와 대화하려면 HTTP로 직접 붙어야 한다. 메시지를 POST하면 답 대신 202 접수 응답이 오고, 답은 그 응답에 실린 offset을 들고 Flue 대화 스트림을 읽어서 받는다. offset은 응답에서 받은 문자열을 저장해 두었다가 돌려주는 위치 토큰이고, 처음부터 다시 읽을 때 쓰는 값은 -1로 따로 정해져 있다. 프런트를 다른 출처에 두고 배포했다면 브라우저가 offset이 실린 헤더를 읽을 수 있도록 CORS를 열어야 하고, 대화 내용이 담기는 트레이스의 수집 범위도 정해야 한다
POST가 돌려준 offset부터 읽으면 지난 기록 없이 답을 받는다
React 없이 HTTP로 붙는 경우로 강의가 드는 예는 모바일 앱과 서버 간 통신이다. 이때 쓰는 표면은 1편 Flue 에이전트는 시스템 프롬프트를 반환하는 함수다에서 본 대화 URL의 라우트다. 예제에서는 fetch만으로 메시지를 보내는 클라이언트를 만들었다
const conversationUrl = "http://localhost:5173/agents/1";
const headers = {
Authorization: "Bearer TRUSTME",
"Content-Type": "application/json",
};
export async function post() {
const response = await fetch(conversationUrl, {
method: "POST",
headers,
body: JSON.stringify({
kind: "user",
body: "Install Node.js on your machine and then build a tiny Node server.",
}),
});
const admission = await response.json();
console.log("POST offset:", admission);
return admission;
}
응답 상태는 202 Accepted다. 이 응답은 메시지가 영속 기록에 들어간 직후, 에이전트가 실행되기 전에 돌아오고 본문에는 네 필드가 있다(Streaming Protocol — Admission response)
| 필드 | 의미 |
|---|---|
streamUrl | 이 대화를 읽는 URL. 요청 URL에서 쿼리를 뺀 값이고 Location 헤더에도 실린다 |
offset | 방금 보낸 메시지까지 기록된 스트림의 끝. Stream-Next-Offset 헤더에도 실린다 |
submissionId | 이 메시지의 처리 단위인 제출(submission)을 가리키는 ID. 종료 기록과 짝을 맞출 때 쓴다 |
uid | 같은 대화 ID로 다시 만들어진 인스턴스를 구분하는 값. 다음 메시지를 보낼 때 uid 조건으로 넘기면 이 인스턴스에만 전달된다 |
이 offset에서 변경분을 읽으면 이전 기록과 방금 보낸 메시지는 다시 오지 않고, 그 뒤에 기록되는 것부터 온다. 답이 나올 때까지 기다려 주는 동기 모드는 없고, ?wait 쿼리를 붙이면 400으로 거부된다. 그래서 HTTP로 직접 붙는 클라이언트는 보내기와 읽기를 늘 두 요청으로 나눠 짠다
offset은 계산하지 않고 받은 값을 그대로 넘긴다
강의는 offset을 어느 지점부터 업데이트를 구독할지 가리키는 포인터로 설명한다. 구독 지점을 고르는 데는 이 설명으로 충분하고, offset을 저장하거나 비교하는 코드를 짤 때는 문서의 정의가 더 필요하다(Streaming Protocol — Offsets). 문서가 드는 예시는 다음과 같다
0000000000000000_0000000000000003
offset은 0을 채운 16자리 정수 두 개를 _로 이은 문자열이고, Flue에서는 앞쪽 정수가 늘 0이다. 이 값이 가리키는 위치의 단위는 대화 영속 기록의 묶음(batch)이고, 메시지 한 건을 처리하는 동안 보통 여러 묶음이 생긴다. 그래서 예제의 Node.js 설치 요청처럼 긴 작업을 보내 놓고 GET을 반복하면 offset이 계속 바뀐다. 한 응답의 모델 단계는 어시스턴트 메시지 하나로 접히므로(Streaming Protocol — Snapshot) 메시지 수는 그만큼 늘지 않는다. 내부 기록만 담긴 묶음은 화면에 보낼 조각(chunk)을 만들지 않으므로, 변경분 응답은 비어 있는데 다음 offset은 앞으로 나아가는 경우도 있다
문서가 정한 규칙은 다음과 같다
- 응답에서 받은 값(접수 응답과 스냅숏의
offset,Stream-Next-Offset헤더, SSE(Server-Sent Events) 연결의control이벤트)을 그대로 돌려준다. 직접 만들거나 숫자로 해석하지 않는다 -1은 첫 묶음 이전을 뜻한다.-1부터 읽으면 대화 전체를 처음부터 다시 받는다- 읽기는 배타적이다. offset
X로 읽으면X다음에 기록된 것부터 온다 -1과 두 숫자 형식 말고는400으로 거부되고, 현재 끝보다 뒤의 offset은500으로 실패한다
직접 구현한 클라이언트라면 offset은 문자열로 저장하고, 두 지점의 앞뒤를 따져야 할 때는 조각마다 붙는 기록 위치인 position을 쓴다. 문서가 비교 방법까지 적어 둔 값이다
HEAD로는 진행만 보고 완료는 submission-settled로 판단한다
GET은 기본값인 view=history로 대화 전체의 스냅숏을 돌려준다. 스냅숏의 offset은 이 스냅숏이 반영한 마지막 위치라서 여기서 변경분을 이어 받으면 빠지거나 겹치는 조각이 없다(Streaming Protocol — Snapshot). 메시지는 필요 없고 위치만 알고 싶으면 HEAD를 쓴다
export async function get() {
const response = await fetch(conversationUrl, {headers});
const conversation = await response.json();
console.log("GET offset:", conversation.offset);
return conversation;
}
export async function head() {
const response = await fetch(conversationUrl, {method: "HEAD", headers});
const metadata = Object.fromEntries(response.headers);
console.log("HEAD headers:", metadata);
return metadata;
}
HEAD는 본문 없이 Stream-Next-Offset(현재 끝 위치)과 Stream-Up-To-Date: true를 헤더로 돌려준다(Streaming Protocol — HEAD /:id). 긴 작업을 보내 놓고 HEAD를 반복해 부르면 Stream-Next-Offset이 계속 바뀌므로, 기록 전체를 내려받지 않고도 에이전트가 아직 움직이는지 볼 수 있다
Stream-Up-To-Date는 스냅숏과 HEAD 응답에서 항상 true이고(Streaming Protocol — Coordination headers), 기록의 끝까지 읽었다는 표시라서 완료 여부와는 상관이 없다. 도구 하나가 오래 걸리면 그동안 기록이 늘지 않을 수 있으니 offset이 멈춘 것도 완료의 신호로 쓸 수 없다. 처리가 끝났는지는 접수 응답의 submissionId를 가진 submission-settled 조각이나 스냅숏의 settlements 항목으로 확정되고, 결과는 completed, failed, aborted 중 하나다. HEAD 폴링은 진행 표시에만 쓰고, 완료 처리는 이 종료 기록에 묶는다
끊긴 SSE는 마지막 control 이벤트의 offset에서 다시 잇는다
새로 생기는 내용은 view=updates에 offset을 붙여 읽는다. 예제에서는 서버가 연결을 열어 둔 채 이벤트를 계속 보내는 SSE로 받아 콘솔에 찍었다
export async function stream(offset: string) {
const response = await fetch(
`${conversationUrl}?view=updates&offset=${encodeURIComponent(offset)}&live=sse`,
{headers},
);
const reader = response.body!.getReader();
const decoder = new TextDecoder();
while (true) {
const {value, done} = await reader.read();
if (done) break;
process.stdout.write(decoder.decode(value));
}
}
보내기와 읽기는 접수 응답의 offset으로 잇는다
const {offset} = await post();
await stream(offset);
live를 생략하면 지금 있는 변경분을 바로 돌려주고(한 응답에 최대 100묶음), long-poll은 새 데이터가 없으면 요청을 붙잡아 두었다가 데이터가 생기면 바로, 30초 안에 생기지 않으면 빈 배열로 답하며, sse는 연결을 열어 둔 채 계속 보낸다(Streaming Protocol — Updates). SSE 연결에는 data·control 두 이벤트와 heartbeat 주석 줄이 흐르고, 문서의 형식을 옮기면 다음과 같다
event: data
data:[{ "type": "message-delta", ... }, ...]
event: control
data:{"streamNextOffset":"0000000000000000_0000000000000007","upToDate":true}
: heartbeat
data 이벤트는 조각 배열이다. control 이벤트는 헤더 대신 다음 offset과 끝까지 따라잡았는지를 알려 주고, 따라잡은 뒤에도 최소 30초에 한 번은 온다. : heartbeat는 15초마다 오는 주석 줄로, 중간 프록시가 유휴 연결을 끊지 않게 한다. 서버는 이 스트림을 끝내지 않으므로 upToDate: true가 온 뒤에도 연결은 유지되고, 에이전트가 이어서 만든 조각과 다음 메시지의 답이 계속 들어온다(Streaming Protocol — live=sse)
조각 타입은 문서의 ConversationStreamChunk 절에 모두 나와 있다(Streaming Protocol — ConversationStreamChunk). 완료와 상태 교체는 다음 조각으로 판단한다. 어시스턴트 응답이 닫히면 message-completed, 제출 하나가 끝나면 그 결과를 담은 submission-settled가 온다. conversation-reset이 오면 쌓아 둔 상태를 함께 실려 온 스냅숏으로 통째로 바꾼다. 이 조각은 -1부터 읽을 때 첫 조각이고, 대화 중 컴팩션이 일어날 때도 온다. 이 조각들로 짜는 클라이언트는 다음을 지킨다
- 연결이 끊기면 마지막
control이벤트의streamNextOffset에서 다시 연결한다. SSE는 재연결 사이에 같은 조각을 다시 보낼 수 있으므로, 조각마다 붙은position의batch와index를 차례로 비교해 이미 적용한 조각을 버린다 - 변경분은 그 offset 시점의 상태에 대한 차이다. 그 offset의 상태를 들고 있을 때만 거기서 이어 받고, 로컬 상태를 잃었다면 스냅숏을 새로 받는다
conversation-reset은 같은 묶음의 다른 조각을 흡수하므로submission-settled조각이 따로 오지 않을 수 있다. 완료를 기다릴 때는 이 조각과 함께 리셋 스냅숏의settlements도 본다
서버는 변경분 읽기마다 그 offset까지의 대화 상태를 다시 계산하고 재생용 캐시를 두지 않아서, 대화가 길수록 읽기 한 번의 준비 비용이 커진다. 문서도 긴 대화라면 재연결 지연을 재 보라고 권한다(Streaming Protocol — Updates). 그래서 응답마다 다시 요청하는 long-poll은 연결을 열어 두는 SSE보다 긴 대화에서 불리할 수 있다고 보는데, 직접 재 보지는 않았다
JavaScript 클라이언트라면 이 규칙을 직접 구현할 필요 없이 @flue/sdk를 쓴다. send()로 보낸 결과를 read()에 넘기면 종료 기록을 기다렸다가 답까지 꺼내 준다(Flue Agent SDK — The HTTP surface it wraps). 화면을 계속 갱신하는 observe()는 연결이 실패하면(인증 오류 같은 400·401·403은 제외) 1초부터 최대 30초까지 늘어나는 간격으로 다시 시도하는데, 마지막 offset에서 잇는 위의 방식과 달리 새 스냅숏을 받아 처음부터 맞춘다(FlueClient — observe()). 1편의 useFlueAgent는 SSE로 이어 받지만 observe()의 live 기본값은 'long-poll'이므로(FlueClient — AgentConversationObserveOptions), 긴 대화에 observe()를 쓴다면 live: 'sse'를 고려한다
다른 출처의 프런트가 부르는 백엔드에는 CORS와 노출 헤더를 연다
백엔드는 npm run deploy(vite build && wrangler deploy)로 Workers에 올리고, 프런트의 createFlueClient 주소를 배포된 주소로 바꾼다
const client = useMemo(() => createFlueClient({
url: "https://flue-backend.<계정 서브도메인>.workers.dev/agents/user-1",
token: "TRUSTME",
}), []);
로컬 프런트에서 이 주소로 접속하면, 로컬끼리는 없던 CORS 오류가 난다. vite dev는 localhost 출처에 허용적인 CORS 기본값을 적용하고, Flue의 에이전트 라우터는 Access-Control-* 헤더를 하나도 붙이지 않는다. 그래서 로컬에서 되던 교차 출처 구성이 배포 후에는 막힌다(Routing — CORS). 백엔드 app.ts에 Hono의 CORS 미들웨어를 붙인다
import {cors} from "hono/cors";
app.use("/agents/*", cors({
origin: "http://localhost:5173",
allowHeaders: ["Content-Type", "Authorization"],
exposeHeaders: ["Location", "Stream-Next-Offset", "Stream-Up-To-Date"],
}));
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(CustomerSupport));
allowHeaders의Authorization은 CORS가 기본으로 허용하는 요청 헤더가 아니고,Content-Type은 값이application/json이면 허용 조건을 벗어나서(MDN — CORS-safelisted request header) 브라우저가 사전 요청(OPTIONS)으로 먼저 허락을 묻는다. Flue 문서 예시처럼allowHeaders를 생략해도 Hono가 사전 요청이 묻는 헤더를 그대로 허용하지만, 여기서는 두 헤더로 좁혔다.GET,HEAD,POST는 따로 허용하지 않아도 되는 메서드라서(Fetch 표준 — CORS-safelisted method)allowMethods는 적지 않아도 된다exposeHeaders에Stream-Next-Offset,Stream-Up-To-Date,Location을 넣는다. 브라우저는 교차 출처 응답에서 기본 헤더만 스크립트에 보여 주므로(MDN — Access-Control-Expose-Headers), 이 셋을 열지 않으면 SDK가 다음 읽기를 이어 갈 offset을 헤더에서 읽지 못한다- CORS 미들웨어를 인증보다 먼저 둔다. 사전 요청에는
Authorization값이 실리지 않고, 브라우저는 사전 요청의 응답 상태가 2xx일 때만 본 요청을 보낸다(Fetch 표준 — CORS-preflight fetch). 인증이 앞에 있으면 사전 요청이401로 막힌다. Hono의cors()는OPTIONS요청에204로 바로 답하고 다음 미들웨어를 부르지 않으므로(Hono v4.13.14 소스 — cors), 앞에 두면 사전 요청이 인증까지 가지 않는다 origin은 브라우저 주소창의 출처와 프로토콜, 호스트, 포트까지 같아야 하고 끝에/를 붙이지 않는다. Hono는 문자열로 받은origin이 요청의 출처와 같을 때만Access-Control-Allow-Origin을 붙인다
Vite 개발 서버는 5173이 쓰이고 있으면 다음 빈 포트로 넘어가므로(Vite — server.port) origin은 프런트가 실제로 뜬 주소에서 확인하고, 배포된 프런트까지 생각하면 환경 변수에서 읽는다. 백엔드를 vite dev로 띄울 때는 Vite의 기본 CORS가 Hono의 CORS 미들웨어와 겹치지 않도록 Hono 문서대로 백엔드 vite.config.ts에 server: { cors: false }를 둔다(Hono — Using with Vite). 그러면 로컬에서도 배포와 같은 CORS 동작을 본다
프런트와 API를 같은 Worker에서 서비스하면 출처가 같아 CORS 설정이 필요 없고, 정적 자산 처리가 에이전트 요청을 가로채지 않도록 assets.run_worker_first에 에이전트를 마운트한 경로(이 예제는 /agents/*)를 넣는다(Deploy to Cloudflare — Serving assets from the same Worker). 공개할 서비스라면 출처를 환경마다 맞출 일이 없는 이 구성이 더 단순하다
대화 내용은 트레이스에도 기본으로 남는다
Cloudflare 타깃에서는 응답 하나가 Workers Traces에 트레이스 하나로 기록되고, 트레이스를 켜 두면 그 안에 에이전트 스팬(span, 트레이스를 이루는 구간 하나하나)이 별도 설정 없이 들어간다(Observability — Cloudflare). 1편의 wrangler.jsonc에서 이미 트레이스를 켜 두었으므로 배포만 하면 볼 수 있다. 문서가 적은 스팬 구성을 트리로 옮기면 다음과 같다
trace (응답 1건)
└─ invoke_agent CustomerSupport ← 실행 전체. 토큰 합계, 입력·출력 메시지
├─ chat <모델> ← 모델 턴마다 1개. 메시지, 시스템 지침, 도구 정의, 토큰 사용량
├─ execute_tool <도구> ← 도구 호출마다 1개. 인자와 결과
└─ chat <모델>
대시보드의 Workers Observability에서 chat 스팬을 펼치면 모델에 넘어간 지침과 메시지가 보이고, 느린 응답의 원인이 모델인지 도구인지는 chat과 execute_tool 스팬의 길이로 가른다
기본 설정에서는 대화 내용이 트레이스에 남는다. 이 타깃에서는 계정에서 트레이스를 켜는 것이 곧 내용 수집을 켜는 일이고, 원시 오류 메시지와 스택, 호출자 쪽에서 실행한 셸(bash) 작업 내용은 어떤 설정에서도 보내지 않는다(Cloudflare target — createCloudflareTracing()). 수집 범위를 바꾸려면 app.ts 모듈 최상단에서 트레이스를 만드는 어댑터를 설치한다. 문서의 설치 예시는 다음과 같다
import { instrument } from '@flue/runtime';
import { createCloudflareTracing } from '@flue/runtime/cloudflare';
instrument(createCloudflareTracing({ content: false }));
content: false를 주면 스팬에서 대화 내용이 빠지고, content: { transform }을 넘기면 내용을 가리거나 일부만 빼는 정책을 코드로 쓸 수 있다. 모듈 최상단에서 등록해야 기본 어댑터를 대신하고, 시작한 뒤에 설치하면 예외가 난다
2편에서 본 이벤트 훅의 log.info(...) 같은 로그는 Workers Logs에 남는데, 로그는 observability.enabled가, 트레이스는 observability.traces.enabled가 켠다. 트레이스가 베타인 지금은 앞의 값이 트레이스까지 켜지 않는다(Cloudflare Docs — How to enable tracing). 1편 설정에는 traces만 있으므로, 로그까지 보려면 강의와 Flue 배포 문서의 예시처럼 enabled를 더해 두 값을 함께 켠다(Deploy to Cloudflare — Observability)
"observability": {
"enabled": true,
"traces": { "enabled": true },
},
트레이스로는 에이전트가 실제로 받은 지침과 메시지를 배포 환경에서 확인할 수 있으므로, 끄기보다는 수집 범위를 정해 두고 쓰는 쪽이 맞다고 본다. 개인정보가 오가는 에이전트라면 content: false로 시작하고, 디버깅에 내용이 필요해지면 transform으로 바꿔 필요한 것만 남긴다
이 시리즈의 글: Flue로 AI 에이전트 만들기
- Flue 에이전트는 시스템 프롬프트를 반환하는 함수다
- Flue 에이전트 훅의 반영 시점은 모델과 프롬프트가 다르다
- Flue durable 도구만 크래시 후 재실행된다
- Flue 스킬·MCP·서브에이전트·샌드박스는 컨텍스트에 들어오는 방식이 다르다
- Flue 대화 스트림은 offset으로 이어 받는다 (이 글)
출처와 범위
이 글의 출발점은 노마드 코더 AX 스쿨의 Flue 강의다. 프로토콜과 CORS, 트레이스 설명은 AI(Claude)와 함께 2026년 11월 1일 기준 Flue 문서와 웹 표준·도구 문서를 대조해 썼다
참고 자료
- Streaming Protocol — Flue Docs
- Flue Agent SDK — Flue Docs
- FlueClient — Flue Docs
- Routing — Flue Docs
- Observability — Flue Docs
- Cloudflare target — Flue Docs
- Deploy to Cloudflare — Flue Docs
- CORS Middleware — Hono
- Workers Traces — Cloudflare Docs