본문으로 건너뛰기

미들웨어

미들웨어를 사용하면 구성부터 스트리밍, 도구 실행, 사용량 추적 및 완료까지 chat() 수명 주기의 모든 단계에 훅을 연결할 수 있습니다. 어댑터나 도구 구현을 수정하지 않고도 각 단계의 동작을 관찰하거나 변환하거나 중단할 수 있습니다.

일반적인 사용 사례는 다음과 같습니다.

  • 로깅 및 관찰 가능성 — 토큰 사용량, 도구 실행 시간 및 오류를 추적합니다.
  • 구성 변환 — 시스템 프롬프트를 주입하고, 반복마다 temperature를 조정하며, 도구를 필터링합니다.
  • 스트림 처리 — 민감한 콘텐츠를 가리고, 청크를 변환하며, 원하지 않는 이벤트를 삭제합니다.
  • 도구 호출 가로채기 — 인수를 검증하고, 결과를 캐시하며, 위험한 호출을 중단합니다.
  • 부수 효과 — 분석 데이터를 전송하고, 데이터베이스를 업데이트하며, 알림을 트리거합니다.

빠른 시작

chat() 함수에 미들웨어 배열을 전달합니다.

import { chat, type ChatMiddleware } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";

const logger: ChatMiddleware = {
name: "logger",
onStart: (ctx) => {
console.log(`[${ctx.requestId}] Chat started`);
},
onFinish: (ctx, info) => {
console.log(`[${ctx.requestId}] Finished in ${info.duration}ms`);
},
};

const stream = chat({
adapter: openaiText("gpt-5.5"),
messages: [{ role: "user", content: "Hello" }],
middleware: [logger],
});

개발 중에 미들웨어를 통과하는 청크를 확인하고 싶으신가요? chat() 호출에 debug: { middleware: true }를 사용하면 됩니다 — 사용자 지정 미들웨어가 필요하지 않습니다. 디버그 로깅을 참조하세요.

수명 주기 개요

모든 chat() 호출은 예측 가능한 수명 주기를 따릅니다. 미들웨어 훅은 특정 단계에서 실행됩니다.

graph TD
A["chat() called"] --> B["onConfig (phase: init)"]
B --> C[onStart]
C --> D["onConfig (phase: beforeModel)"]
D --> E["Adapter streams response"]
E --> F["onChunk (for each chunk)"]
F --> G{Tool calls?}
G -->|No| H[onUsage]
G -->|Yes| I[onBeforeToolCall]
I --> J[Tool executes]
J --> K[onAfterToolCall]
K --> L{Continue loop?}
L -->|Yes| D
L -->|No| H
H --> SO{"Structured output path?"}
SO -->|None| M{Outcome}
SO -->|"Native combined"| SOH["Post-loop structured-output harvest (onChunk)"]
SOH --> M
SO -->|"Separate finalization"| SOC[onStructuredOutputConfig]
SOC --> SOM["onConfig (phase: structuredOutput)"]
SOM --> SOS["Structured-output finalization (onChunk, onUsage)"]
SOS --> M
M -->|Success| N[onFinish]
M -->|Abort| O[onAbort]
M -->|Error| P[onError]

style I fill:#e1f5ff
style J fill:#ffe1e1
style SOC fill:#e1f5ff
style SOM fill:#e1f5ff
style SOS fill:#e1f5ff
style SOH fill:#e1f5ff
style N fill:#e1ffe1
style O fill:#fff4e1
style P fill:#ffe1e1

단계 전환

컨텍스트의 phase 필드는 수명 주기에서 현재 위치를 추적합니다.

단계시점호출되는 훅
init시작 시 한 번onConfig
beforeModel각 모델 호출 전 (각 반복마다)onConfig
modelStream어댑터가 청크를 스트리밍하는 동안onChunk, onUsage
beforeTools도구 실행 전onBeforeToolCall
afterTools도구 실행 후onAfterToolCall
structuredOutput최종 구조화된 출력 어댑터 호출 중입니다(outputSchema가 설정되어 있고 현재 모델/옵션에 대해 supportsCombinedToolsAndSchema()true를 반환하지 않는 경우). adapter.structuredOutputStream의 청크(또는 생성된 비스트리밍 대체 경로)는 이 단계에서 onChunk를 거치며, 최종 호출의 토큰에 대해 onUsage가 실행됩니다. 실행되지 않는 경우는 도구와 스키마를 하나의 스트리밍 호출에서 기본적으로 결합하는 어댑터입니다(최신 OpenAI Chat Completions, OpenAI Responses, Claude 4.5+, Gemini 3.x, Grok 4.x 제품군 및 라우팅된 모든 모델이 OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS에 포함된 경우의 OpenRouter이며, issue #605를 참조하세요). 이 경로에서 미들웨어는 평소처럼 beforeModel / modelStream을 통해 실행을 관찰합니다.onStructuredOutputConfig, onConfig, onChunk, onUsage

훅 참조

onConfig

init(시작) 중 한 번, beforeModel(각 모델 호출 전) 중 반복마다 한 번 실행됩니다. 별도 최종화 경로에서는 구조화된 출력 경계에서 ctx.phase === 'structuredOutput'인 상태로 onConfig가 추가로 다시 실행되며, onStructuredOutputConfig 이후의 구성 뷰를 받습니다. 따라서 단일 반복의 별도 최종화 실행에서는 onConfig가 세 번 실행됩니다(init + beforeModel + structuredOutput). 네이티브 결합 출력에서는 이 세 번째 호출이 추가되지 않습니다. 모델이 받는 구성을 변환하려면 onConfig를 사용합니다.

변경하려는 필드만 포함한 부분 구성 객체를 반환합니다. 현재 구성과 자동으로 얕게 병합되므로 기존 구성을 전개할 필요가 없습니다.

import { type ChatMiddleware } from "@tanstack/ai";

const dynamicTemperature: ChatMiddleware = {
name: "dynamic-temperature",
onConfig: (ctx, config) => {
if (ctx.phase === "init") {
// Add a system prompt at startup — only systemPrompts is overwritten
return {
systemPrompts: [
...config.systemPrompts,
"You are a helpful assistant.",
],
};
}

if (ctx.phase === "beforeModel" && ctx.iteration > 0) {
// Increase temperature on retries. Sampling params live in the
// provider-native modelOptions object — `temperature` is universal,
// so it's the same key across providers. Spread the existing
// modelOptions so other model options stay unchanged.
const current =
typeof config.modelOptions?.temperature === "number"
? config.modelOptions.temperature
: 0.7;
return {
modelOptions: {
...config.modelOptions,
temperature: Math.min(current + 0.1, 1.0),
},
};
}
},
};

샘플링 매개변수(temperature, top_p / topP, 다양한 max*Tokens 키)는 각 제공자의 네이티브 이름으로 modelOptions 내부에 있으며, 더 이상 루트 구성 필드가 아닙니다. 모든 제공자에서 temperature의 표기가 우연히 같으므로 위 예제는 제공자에 종속되지 않습니다. 대신 토큰 제한을 변경한다면 제공자 네이티브 키를 사용합니다(예: OpenAI의 max_output_tokens, Ollama에서 modelOptions.options 아래에 중첩된 num_predict). 샘플링 옵션을 modelOptions로 이동을 참조하세요.

변환할 수 있는 구성 필드:

필드타입설명
messagesModelMessage[]표준 대화 기록입니다. 영속성과 ctx.messages가 이 필드를 사용합니다.
providerMessagesModelMessage[]제공자에게 보내는 임시 컨텍스트입니다. 기본값은 messages입니다.
systemPromptsstring[]시스템 프롬프트
toolsTool[]사용 가능한 도구
metadataRecord<string, unknown>요청 메타데이터
modelOptionsRecord<string, unknown>제공자 네이티브 옵션입니다. 샘플링 매개변수(temperature, top_p / topP, 제공자의 max*Tokens 키)를 비롯한 모든 모델별 옵션이 여기에 있습니다. 샘플링 옵션을 modelOptions로 이동을 참조하세요.

여러 미들웨어가 onConfig를 정의하면 구성이 순서대로 미들웨어를 통과하며, 각 미들웨어는 이전 미들웨어에서 병합된 구성을 받습니다.

변환이 모델 호출에만 영향을 줘야 할 때는 providerMessages를 반환합니다. 호환성을 위해 동일한 결과에서 providerMessages를 명시적으로 설정하지 않는 한 messages를 반환해도 제공자 입력이 업데이트됩니다.

onStructuredOutputConfig

최종 구조화된 출력 어댑터 호출 시작 시 한 번 실행되며, outputSchema와 함께 chat()이 호출되고 현재 모델/옵션에 대해 supportsCombinedToolsAndSchema()true를 반환하지 않는 경우에만 실행됩니다. onConfig와 마찬가지로 미들웨어를 순서대로 통과하지만, 제공자에게 전송되는 JSON 스키마에 접근할 수 있습니다. 스키마를 변환하거나(예: $defs 주입, 제공자 호환되지 않는 키워드 제거) 구조화된 출력 전용 동작을 적용해야 할 때(예: 최종 호출에서 시스템 프롬프트 억제) 이 훅을 사용합니다.

네이티브 결합 어댑터(최신 OpenAI, Claude 4.5+, Gemini 3.x, Grok 4.x — issue #605 참조)는 별도의 최종화 호출을 건너뛰며 이 훅을 호출하지 않습니다. 엔진은 onConfig 실행 후 변환된 스키마를 chatStream에 직접 전달하므로 미들웨어가 네이티브 결합 스키마를 변환할 수 없습니다.

변경하려는 필드만 포함한 부분 StructuredOutputMiddlewareConfig를 반환합니다. 현재 구성과 얕게 병합됩니다. 그대로 통과시키려면 void를 반환합니다.

import { type ChatMiddleware } from "@tanstack/ai";
import { sharedDefs } from "./defs";

const injectDefs: ChatMiddleware = {
name: "inject-defs",
onStructuredOutputConfig: (_ctx, config) => {
// `config.outputSchema` is the JSON Schema being sent to the provider
return {
outputSchema: {
...config.outputSchema,
$defs: { ...sharedDefs },
},
};
},
};

변환할 수 있는 구성 필드:

필드타입설명
messagesModelMessage[]표준 대화 이력
providerMessagesModelMessage[]최종 호출에 전송되는 임시 컨텍스트
systemPromptsSystemPrompt[]최종 호출의 시스템 프롬프트
metadataRecord<string, unknown>요청 메타데이터
modelOptionsRecord<string, unknown>Provider-native 옵션 — 샘플링 매개변수(temperature, top_p / topP, provider의 max*Tokens 키)가 이제 여기에 있으며, 기타 모든 모델별 설정도 함께 위치합니다. 샘플링 옵션을 modelOptions로 이동하기를 참조하세요.
outputSchemaJSONSchema구조화된 출력을 위해 제공자에게 전송되는 JSON 스키마

구조화된 출력 경계에서의 순서:

  1. onStructuredOutputConfig가 먼저 실행되며 배열 순서에 따라 모든 미들웨어를 통과합니다.
  2. 그런 다음 동일한 경계에서 ctx.phase === 'structuredOutput'인 상태로 onConfig가 다시 실행되며, onStructuredOutputConfig 이후의 구성 뷰(outputSchema 제외)를 받습니다. 모든 어댑터 호출에 적용되는 범용 변환에는 onConfig를 사용하고, 스키마에 접근해야 할 때는 onStructuredOutputConfig를 사용합니다.

여러 미들웨어가 onStructuredOutputConfig를 정의하면 구성이 순서대로 미들웨어를 통과하며, 각 미들웨어는 이전 미들웨어에서 병합된 구성을 받습니다.

onStart

초기 onConfig가 완료된 후 한 번 실행됩니다. 타이머 초기화나 로깅 같은 설정 작업에 사용합니다.

import { type ChatMiddleware } from "@tanstack/ai";

const timer: ChatMiddleware = {
name: "timer",
onStart: (ctx) => {
console.log(`Request ${ctx.requestId} started at iteration ${ctx.iteration}`);
},
};

onChunk

어댑터에서 스트리밍되는 모든 청크에 대해 실행됩니다. 청크를 관찰하거나 변환하거나 확장하거나 삭제할 수 있습니다.

import { type ChatMiddleware } from "@tanstack/ai";

const redactor: ChatMiddleware = {
name: "redactor",
onChunk: (ctx, chunk) => {
if (chunk.type === "TEXT_MESSAGE_CONTENT") {
// Transform: redact sensitive content
return {
...chunk,
delta: chunk.delta.replace(/\b\d{3}-\d{2}-\d{4}\b/g, "[REDACTED]"),
};
}
// Return void to pass through unchanged
},
};

반환 값:

반환효과
void / undefined청크를 변경하지 않고 통과시킵니다
StreamChunk원래 청크를 대체합니다
StreamChunk[]여러 청크로 확장합니다
null청크를 완전히 삭제합니다

여러 미들웨어가 onChunk를 정의하면 청크가 순서대로 미들웨어를 통과합니다. 한 미들웨어가 청크를 삭제(null 반환)하면 이후 미들웨어는 이를 볼 수 없습니다.

확인할 수 있는 청크 유형

onChunk는 실행에서 생성되는 모든 AG-UI 이벤트를 받으며 텍스트만 받는 것이 아닙니다. 타입별 필드를 읽기 전에 판별된 유니온인 chunk.type으로 범위를 좁힙니다. 일반적인 이벤트는 다음과 같습니다.

chunk.type의미주요 필드
RUN_STARTED / RUN_FINISHED / RUN_ERROR실행 수명 주기 경계runId, finishReason, 완료 시 usage, 오류 시 message
TEXT_MESSAGE_START / TEXT_MESSAGE_CONTENT / TEXT_MESSAGE_END어시스턴트 텍스트 스트리밍messageId, 콘텐츠인 delta
TOOL_CALL_START / TOOL_CALL_ARGS / TOOL_CALL_END도구 호출 스트리밍toolCallId, toolCallName, 인수인 delta, 종료 시 결과
STEP_STARTED / STEP_FINISHED사고/추론 단계delta, signature
STATE_SNAPSHOT / STATE_DELTA에이전트 상태 동기화snapshot, delta
CUSTOM확장성 이벤트(아래의 구조화된 출력 포함)name, value

전체 이벤트 카탈로그와 정확한 필드 형태는 AG-UI 프로토콜 문서를 참조하세요.

구조화된 출력 청크 변환

별도의 onStructuredOutputChunk 훅은 없으며, 필요하지도 않습니다. outputSchema와 함께 chat()이 호출되면 구조화된 출력 청크(JSON TEXT_MESSAGE_CONTENT 델타, structured-output.start / structured-output.complete CUSTOM 이벤트 및 모든 최종화 RUN_ERROR)가 다른 모든 청크와 동일한 onChunk을 통과합니다. 다른 청크와 똑같이 변환하거나 확장하거나 삭제합니다.

이를 구분하는 방법은 어댑터가 사용하는 최종화 경로에 따라 달라집니다.

  • 별도 최종화 어댑터(현재 모델/옵션에 대해 supportsCombinedToolsAndSchema()true를 반환하지 않음): 최종화 호출 중 ctx.phase === 'structuredOutput'입니다. 단계를 기준으로 구분합니다.
  • 네이티브 결합 어댑터(최신 OpenAI Chat Completions / Responses, Claude 4.5+, Gemini 3.x, Grok 4.x — issue #605 참조): 스키마 제약 JSON이 모델의 자연스러운 최종 차례에 생성되므로 ctx.phase'modelStream'으로 유지됩니다. 'structuredOutput' 단계는 실행되지 않습니다. 대신 CUSTOM 이벤트 이름(structured-output.start / structured-output.complete)을 기준으로 구분합니다.
import { type ChatMiddleware } from "@tanstack/ai";

const redactStructuredOutput: ChatMiddleware = {
name: "redact-structured-output",
onChunk: (ctx, chunk) => {
// Separate-finalization path: the JSON streams as TEXT_MESSAGE_CONTENT
// during the 'structuredOutput' phase. Transform the delta like any
// other text chunk — here, redact anything that looks like an SSN before
// it reaches the client.
if (
ctx.phase === "structuredOutput" &&
chunk.type === "TEXT_MESSAGE_CONTENT"
) {
return {
...chunk,
delta: chunk.delta.replace(/\b\d{3}-\d{2}-\d{4}\b/g, "[REDACTED]"),
};
}

// Both paths: the completed typed payload arrives as a CUSTOM
// `structured-output.complete` event. On the native-combined path this is
// your only signal (ctx.phase never flips to 'structuredOutput'), so key
// off the event name, not the phase. `chunk.value` carries { object, raw }.
if (chunk.type === "CUSTOM" && chunk.name === "structured-output.complete") {
console.log("final structured output:", chunk.value);
}

// Return void to pass everything else through unchanged.
},
};

onStructuredOutputConfig는 있지만 onStructuredOutputChunk가 없는 이유는 무엇인가요? 구조화된 출력 경계에서는 구성 형태가 실제로 다르기 때문입니다. 일반 ChatMiddlewareConfig에는 없는 outputSchema 필드를 포함합니다(onStructuredOutputConfig 참조). 청크는 단계와 관계없이 모두 StreamChunk이므로 하나의 onChunkctx.phase(또는 CUSTOM 이벤트 이름)로 모든 경우를 처리할 수 있으며, 별도의 청크 훅은 중복됩니다.

onShouldContinue

엔진이 다른 에이전트 루프 반복을 시작할지 결정할 때(도구 단계 후 또는 모델 차례 사이) 호출됩니다. 미들웨어 간 AND 의미 및 agentLoopStrategy와 결합되며, 명시적인 false는 루프를 중지합니다. 계속하려면 true, void 또는 undefined를 반환합니다.

실행을 중단하지 않습니다. 스트림은 현재 메시지와 함께 정상적으로 완료됩니다. 강제 중단에만 ctx.abort()를 사용합니다.

import { type ChatMiddleware } from "@tanstack/ai";

const budget: ChatMiddleware = {
name: "tool-budget",
onShouldContinue: (_ctx, state) => {
// Stop further turns once 20 tool calls have been emitted
if (state.toolCallCount >= 20) return false;
},
};

턴별 및 누적 도구 예산 레시피는 도구 호출 예산을 참조하세요.

onInterruptBoundary 및 onInterruptResolution

미들웨어가 클라이언트의 데이터를 필요로 할 때 이 훅을 사용합니다. defineInterrupt()로 요청을 정의하고 chat({ interrupts })useChat({ interrupts })에 등록합니다. 미들웨어에서 원시 AG-UI 이벤트를 내보내지 마세요.

onInterruptBoundary 는 에이전트 반복에서 네 가지 지점에서 실행됩니다:

  • beforeModel, 어댑터가 시작되기 전.
  • afterModel, 모델 응답이 완료된 후.
  • beforeTools, 도구 실행이 시작되기 전.
  • afterTools, 도구 단계가 완료된 후.

각 미들웨어는 하나의 경계에서 요청을 반환할 수 있습니다. 엔진은 해당 경계의 모든 요청을 하나의 AG-UI 인터럽트 배치로 결합합니다. 배치는 하나의 인터럽트 결과와 함께 실행을 종료합니다.

이 훅은 구성을 변경할 수 없습니다. 허용되는 반환값은 { interrupts } 또는 없음뿐입니다. 계속 실행은 새로운 chat() 호출이므로 훅이 다시 실행됩니다. 이 일시 중지가 원래 요청에만 속한다면 ctx.parentRunId가 설정된 경우 이벤트를 내보내지 않습니다.

각 단계에서 ctx에 포함되는 내용과 각 단계를 사용하는 시점은 수명 주기 경계에 설명되어 있습니다.

공유 정의 하나를 만듭니다. 서버와 클라이언트가 모두 이 값을 가져오므로 양쪽의 정의 ID와 응답 형태가 동일하게 유지됩니다.

review-plan.ts
import { defineInterrupt, type ChatMiddleware } from '@tanstack/ai'
import { z } from 'zod'

export const reviewPlan = defineInterrupt({
id: 'review-plan',
payloadSchema: z.object({ title: z.string() }),
responseSchema: z.object({ approved: z.boolean() }),
})

export const reviewMiddleware: ChatMiddleware<unknown, typeof reviewPlan> = {
name: 'review-plan',
onInterruptBoundary(ctx) {
if (ctx.phase !== 'beforeTools') return
if (ctx.parentRunId) return
return {
interrupts: [
reviewPlan.interrupt({
key: 'release-plan',
reason: 'review-required',
message: 'Approve this plan?',
payload: { title: 'Release plan' },
}),
],
}
},
onInterruptResolution(_ctx, resumedInterrupts) {
for (const result of resumedInterrupts.for(reviewPlan)) {
if (result.status === 'resolved' && !result.response.approved) {
return { toolResume: 'stop' }
}
}
},
}

서버에 정의를 등록합니다. 클라이언트의 해결이 전체 컨텍스트로 계속 실행을 시작하도록 parentRunIdresume을 전달합니다.

route.ts
import {
chat,
chatParamsFromRequestBody,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { reviewMiddleware, reviewPlan } from './review-plan'

export async function POST(request: Request) {
const params = await chatParamsFromRequestBody(await request.json())
const stream = chat({
adapter: openaiText('gpt-5.5'),
messages: params.messages,
threadId: params.threadId,
runId: params.runId,
...(params.parentRunId ? { parentRunId: params.parentRunId } : {}),
...(params.resume ? { resume: params.resume } : {}),
interrupts: [reviewPlan],
middleware: [reviewMiddleware],
})

return toServerSentEventsResponse(stream)
}

클라이언트에도 동일한 정의를 등록합니다. kinddefinitionId를 확인합니다. 그러면 TypeScript가 항목을 GenericInterrupt<typeof reviewPlan>으로 취급합니다. resolveInterruptreviewPlan.responseSchema의 응답 형태를 사용합니다.

review-plan-panel.tsx
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
import type { GenericInterrupt } from '@tanstack/ai-react'
import { reviewPlan } from './review-plan'

function ReviewCard({
interrupt,
}: {
interrupt: GenericInterrupt<typeof reviewPlan>
}) {
return (
<button
onClick={() => interrupt.resolveInterrupt({ approved: true })}
>
Approve plan
</button>
)
}

export function ReviewPlanPanel() {
const { interrupts, sendMessage } = useChat({
connection: fetchServerSentEvents('/api/chat'),
interrupts: [reviewPlan],
})

return (
<>
<button onClick={() => sendMessage('Review the release plan')}>
Start review
</button>
{interrupts.map((interrupt) => {
if (interrupt.kind !== 'generic') return null
if (!('definitionId' in interrupt)) return null
if (interrupt.definitionId !== reviewPlan.id) return null
return <ReviewCard key={interrupt.id} interrupt={interrupt} />
})}
</>
)
}

일시 중지된 chat() 호출에서는 onInterruptResolution이 실행되지 않습니다. 이 훅은 클라이언트가 응답한 후 다음 chat() 호출 시작 시 한 번 실행됩니다.

setup
onConfig (phase is init)
onInterruptResolution (phase is still init)
onStart
then stop, or continue the agent loop

useChat은 두 번째 요청에 parentRunIdresume을 보냅니다. 각 제네릭 재개 항목에는 metadata에 원래 요청이 포함됩니다. resume이 있고 parentRunId가 없으면 서버가 예외를 발생시킵니다.

resumedInterrupts.for(definition) 를 하나의 타입 정의에 사용합니다. resumedInterrupts.all() 을 등록한 모든 정의에 사용합니다. resumedInterrupts.all(definitionA, definitionB) 를 타입화된 부분집합을 읽기 위해 사용합니다.

훅은 toolResume: 'continue', 'cancel' 또는 'stop'을 반환할 수 있습니다. 모든 미들웨어의 결과는 가장 제한적인 규칙으로 결합됩니다. stopcancel보다 우선하고 cancelcontinue보다 우선합니다.

이 훅은 프롬프트, 도구 또는 메시지를 변경할 수 없습니다. 답변을 capability에 저장한 다음 ctx.phase === 'beforeModel'일 때 onConfig에서 해당 필드를 반환합니다.

변경 가능 항목
onInterruptBoundary없음. 일시 중지만 수행할 수 있습니다.
onInterruptResolutionPending-tool 정책 (toolResume)
onConfigmessages, systemPrompts, tools, modelOptions, metadata

전체 재개 순서와 사용자 메모를 시스템 프롬프트에 작성하는 예제는 답변 적용에 있습니다.

onBeforeToolCall

각 도구가 실행되기 전에 호출됩니다. void가 아닌 결정을 반환하는 첫 번째 미들웨어가 단락 처리하므로 해당 도구 호출에서는 나머지 미들웨어를 건너뜁니다.

import { type ChatMiddleware } from "@tanstack/ai";

function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null;
}

const guard: ChatMiddleware = {
name: "guard",
onBeforeToolCall: (ctx, hookCtx) => {
// Block dangerous tools
if (hookCtx.toolName === "deleteDatabase") {
return { type: "abort", reason: "Dangerous operation blocked" };
}

// Validate and transform arguments
if (hookCtx.toolName === "search" && isRecord(hookCtx.args) && !hookCtx.args.limit) {
return {
type: "transformArgs",
args: { ...hookCtx.args, limit: 10 },
};
}
},
};

결정 유형:

결정효과
void / undefined정상적으로 계속 진행, 다음 미들웨어가 결정할 수 있음
{ type: 'transformArgs', args }실행 전에 도구 인수를 대체
{ type: 'skip', result }실행을 완전히 건너뛰고 제공된 결과를 사용
{ type: 'abort', reason? }전체 채팅 실행을 중단

The hookCtx 제공합니다:

필드타입설명
toolCallToolCall원본 도구 호출 객체
toolTool | undefined해결된 도구 정의
argsunknown파싱된 인자
toolNamestring도구 이름
toolCallIdstring도구 호출 ID

onAfterToolCall

각 도구 실행(또는 건너뛰기) 후 호출됩니다. 모든 미들웨어가 실행되며 단락 처리는 없습니다.

import { type ChatMiddleware } from "@tanstack/ai";

const toolLogger: ChatMiddleware = {
name: "tool-logger",
onAfterToolCall: (ctx, info) => {
if (info.ok) {
console.log(`${info.toolName} completed in ${info.duration}ms`);
} else {
console.error(`${info.toolName} failed:`, info.error);
}
},
};

info 객체는 다음을 제공합니다.

필드타입설명
toolCallToolCall원본 도구 호출 객체
toolTool | undefined해결된 도구 정의
toolNamestring도구 이름
toolCallIdstring도구 호출 ID
okboolean실행 성공 여부
durationnumber밀리초 단위 실행 시간
resultunknownok가 true일 때의 결과
errorunknownok가 false일 때의 오류

onUsage

RUN_FINISHED 청크에 사용량 데이터가 포함될 때 모델 반복마다 한 번 호출됩니다. 사용량 객체를 직접 받습니다.

import { type ChatMiddleware } from "@tanstack/ai";

const usageTracker: ChatMiddleware = {
name: "usage-tracker",
onUsage: (ctx, usage) => {
console.log(
`Iteration ${ctx.iteration}: ${usage.totalTokens} tokens`
);
},
};

usage 객체는 다음과 같습니다.

필드타입설명
promptTokensnumber입력 토큰
completionTokensnumber출력 토큰
totalTokensnumber총 토큰

종료 훅: onFinish, onAbort, onError

chat() 호출마다 종료 훅이 정확히 하나 실행됩니다. 서로 배타적입니다.

실행 시점
onFinish실행이 정상적으로 완료됨
onAbort실행이 중단됨(ctx.abort(), 외부 AbortSignal 또는 onBeforeToolCall{ type: 'abort' } 결정)
onError처리되지 않은 오류가 발생함

별도 최종화 경로: 네이티브 결합을 지원하지 않는 어댑터는 에이전트 루프 후 별도의 구조화된 출력 제공자 호출을 수행합니다.

  • onStructuredOutputConfig는 별도 제공자 호출 전에 실행되며 해당 청크의 ctx.phase'structuredOutput'입니다.
  • 최종화에서는 onIteration실행되지 않으며, 에이전트 루프 반복에서만 실행됩니다.
  • 최종화가 완료된 후 onFinish가 실행됩니다. info 객체에는 에이전트 루프의 종료 상태가 반영됩니다.
  • info.content — 에이전트 루프에서 누적된 텍스트입니다. 별도 최종화 JSON 델타는 포함되지 않습니다. 미들웨어는 onChunkstructured-output.complete CUSTOM 이벤트를 통해 완료된 결과를 관찰할 수 있습니다.
  • info.usage — 에이전트 루프의 마지막 RUN_FINISHED.usage입니다. 도구 없는 구조화된 출력 실행(에이전트 루프 반복에서 RUN_FINISHED가 생성되지 않음)에서는 undefined입니다. 최종화 토큰을 수집하려면 onUsage를 사용합니다. 이 훅은 최종화 호출을 포함해 사용량이 포함된 모든 RUN_FINISHED에 대해 실행됩니다.
  • info.finishReason — 에이전트 루프의 마지막 finishReason입니다. 에이전트 루프 반복에서 RUN_FINISHED가 생성되지 않으면 null입니다(예: 도구 없는 구조화된 출력 실행).
  • info.duration — 최종화를 포함한 전체 chat() 호출의 실제 경과 시간입니다.

네이티브 결합 출력: 네이티브 결합을 지원하는 어댑터는 일반 에이전트 루프 스트림에서 스키마 제약 JSON을 생성합니다. onStructuredOutputConfig는 실행되지 않고 ctx.phase'modelStream'으로 유지되며 JSON을 생성하는 반복에서 onIteration이 실행됩니다. JSON은 에이전트 루프 텍스트이므로 info.content에 포함됩니다. 미들웨어는 같은 단계에서 onChunkstructured-output.complete 이벤트를 관찰합니다.

어느 경로에서든 성공적으로 완료되면 onFinishctx.messages에서 완전한 표준 트랜스크립트를 받습니다. 네이티브 결합 출력은 종료 어시스턴트 메시지에 구조화된 결과를 유지합니다. 별도 최종화 경로에서는 에이전트 루프의 일반 텍스트 어시스턴트 메시지 뒤에 별도의 구조화된 출력 어시스턴트 메시지를 보존할 수 있습니다. 이 트랜스크립트는 info의 경로별 필드와 별개입니다.

전체 실행의 사용량을 집계하려면 info.usage에 의존하지 말고 onUsage 콜백에서 누적합니다.

import { type ChatMiddleware } from "@tanstack/ai";

const terminal: ChatMiddleware = {
name: "terminal",
onFinish: (ctx, info) => {
console.log(`Finished: ${info.finishReason}, ${info.duration}ms`);
console.log(`Content: ${info.content}`);
if (info.usage) {
console.log(`Tokens: ${info.usage.totalTokens}`);
}
},
onAbort: (ctx, info) => {
console.log(`Aborted: ${info.reason}, ${info.duration}ms`);
},
onError: (ctx, info) => {
console.error(`Error after ${info.duration}ms:`, info.error);
},
};

onFinishinfo 객체(FinishInfo)는 다음과 같습니다.

필드타입설명
finishReasonstring | null에이전트 루프의 마지막 finishReason입니다. 에이전트 루프 반복에서 null가 생성되지 않으면 RUN_FINISHED입니다(예: 도구 없는 chat({ outputSchema }) 실행).
durationnumber구조화된 출력 최종화를 포함한 전체 실행 시간(밀리초)입니다.
contentstring에이전트 루프에서 누적된 텍스트 콘텐츠입니다. 네이티브 결합 구조화 JSON은 포함하고 별도 최종화 JSON은 제외합니다. structured-output.complete를 통해 onChunk CUSTOM 이벤트로 완료된 결과를 관찰합니다.
usage{ promptTokens; completionTokens; totalTokens } | undefined선택 사항입니다. 에이전트 루프의 마지막 RUN_FINISHED.usage입니다. 최종화 토큰은 포함하지 않습니다. 이를 관찰하려면 onUsage를 사용합니다. 항상 if (info.usage) 또는 info.usage?.로 확인합니다.

컨텍스트 객체

모든 훅은 첫 번째 인수로 ChatMiddlewareContext를 받습니다. 요청 범위 정보와 제어 함수를 제공합니다.

필드타입설명
requestIdstring이 채팅 요청의 고유 ID
streamIdstring이 스트림의 고유 ID
threadIdstringAG-UI 스레드 식별자입니다. 호출자가 제공한 threadId(또는 기존 conversationId)로 확인되며, 둘 다 없으면 자동 생성됩니다. 이벤트 상관 관계에 사용합니다.
conversationIdstring | undefinedthreadId지원 중단 예정 별칭입니다. 항상 ctx.threadId와 같으며, AG-UI 이름 변경 전에 작성된 미들웨어의 작동을 위해 유지됩니다. 새 미들웨어에서는 ctx.threadId를 읽어야 합니다.
phaseChatMiddlewarePhase현재 수명 주기 단계
iterationnumber에이전트 루프 반복(0부터 시작)
chunkIndexnumber지금까지 생성된 청크 수
signalAbortSignal | undefined외부 중단 시그널
abort(reason?)function미들웨어 내부에서 실행을 중단합니다
emitCustomEvent(name, value)function지금 채팅 스트림에 CUSTOM 청크를 추가합니다. 현재 훅이 실행 중이어도 엔진이 이를 생성하며 onConfig 중에도 동일합니다.
contextTContext사용자가 제공한 런타임 컨텍스트 값
defer(promise)function차단하지 않는 부수 효과를 등록합니다

타입화된 런타임 컨텍스트

ChatMiddleware는 컨텍스트 제네릭을 받습니다. 이를 통해 chat() 외부에서 선언한 재사용 가능한 미들웨어가 도구와 동일한 타입의 런타임 컨텍스트에 접근할 수 있습니다.

import { chat, type ChatMiddleware } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { session, audit } from "./server";

type AppContext = {
userId: string;
audit: {
write(event: { userId: string; requestId: string }): Promise<void>;
};
};

export const auditMiddleware: ChatMiddleware<AppContext> = {
name: "audit",
onStart(ctx) {
ctx.defer(
ctx.context.audit.write({
userId: ctx.context.userId,
requestId: ctx.requestId,
})
);
},
};

chat({
adapter: openaiText("gpt-5.5"),
messages: [{ role: "user", content: "Hello" }],
middleware: [auditMiddleware],
context: {
userId: session.user.id,
audit,
},
});

타입이 지정된 미들웨어나 도구가 있으면 chat()은 제공된 context가 필수 형태와 일치하는지 확인합니다. 일반 ChatMiddleware로 타입이 지정된 기존 미들웨어도 계속 작동하며, 해당 미들웨어의 ctx.contextunknown으로 유지되고 context 옵션을 강제하지 않습니다.

런타임 컨텍스트는 프로세스 로컬 애플리케이션 상태입니다. 이는 chatParamsFromRequest가 파싱하는 프로토콜 메타데이터인 AG-UI RunAgentInput.context와 별개입니다. 서버, 클라이언트 및 클라이언트-서버 전달 패턴은 런타임 컨텍스트를 참조하세요.

미들웨어에서 중단하기

실행을 정상적으로 중지하려면 ctx.abort()를 호출합니다. 그러면 onAbort 종료 훅이 실행됩니다.

import { type ChatMiddleware } from "@tanstack/ai";

const timeout: ChatMiddleware = {
name: "timeout",
onChunk: (ctx) => {
if (ctx.chunkIndex > 1000) {
ctx.abort("Too many chunks");
}
},
};

지연된 부수 효과

스트림을 차단하지 않고 종료 훅 이후 실행할 프로미스를 등록하려면 ctx.defer()를 사용합니다.

import { type ChatMiddleware } from "@tanstack/ai";

const analytics: ChatMiddleware = {
name: "analytics",
onFinish: (ctx, info) => {
ctx.defer(
fetch("/api/analytics", {
method: "POST",
body: JSON.stringify({
requestId: ctx.requestId,
duration: info.duration,
tokens: info.usage?.totalTokens,
}),
})
);
},
};

여러 미들웨어 구성

미들웨어는 배열 순서대로 실행됩니다. 순서는 통과 또는 단락 처리하는 훅에서 중요합니다.

import { chat, type ChatMiddleware } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { authMiddleware, loggingMiddleware, cachingMiddleware } from "./middleware";

const stream = chat({
adapter: openaiText("gpt-5.5"),
messages: [{ role: "user", content: "Hello" }],
middleware: [authMiddleware, loggingMiddleware, cachingMiddleware],
});

조합 규칙

구성 방식순서의 효과
onConfig파이프 처리 — 각각 이전 출력을 받음앞선 미들웨어가 먼저 변환함
onStructuredOutputConfig파이프 처리 — 각각 이전 출력을 받음앞선 미들웨어가 먼저 변환함
onStart순차 실행모두 순서대로 실행됨
onChunk파이프 처리 — 청크가 각 미들웨어를 통과함첫 번째 미들웨어가 청크를 삭제하면 이후 미들웨어는 이를 볼 수 없음
onBeforeToolCall선착순 적용 — 처음 반환된 void가 아닌 결정이 적용됨앞선 미들웨어가 우선함
onShouldContinueAND — 명시적인 false가 하나라도 있으면 루프를 중지함단락 평가 시 어떤 미들웨어가 먼저 실행되는지만 순서의 영향을 받음
onAfterToolCall순차 실행모두 순서대로 실행됨
onUsage순차 실행모두 순서대로 실행됨
onFinish/onAbort/onError순차 실행모두 순서대로 실행됨

기능

미들웨어는 상태를 공유해야 하는 경우가 많습니다. 제공자 미들웨어가 데이터베이스 핸들, 요청별 카운터 또는 샌드박스 등을 설정하면 소비자 미들웨어가 동일한 실행에서 나중에 이를 읽습니다. capability를 사용하면 이 전달을 타입 안전하고 순서가 검사되도록 만들 수 있습니다. 소비자는 필요한 항목을 선언하고 제공자는 제공하는 항목을 선언하며, 필수 capability가 제공되지 않으면 chat()은 컴파일 시점과 런타임 모두에서 실행을 거부합니다.

capability 생성

capability는 createCapability<TValue>()('name')이라는 커리된 호출로 생성합니다.

import { createCapability } from "@tanstack/ai";

const counterCapability = createCapability<{ value: number }>()("counter");
const [getCounter, provideCounter] = counterCapability;

커링은 의도적인 것입니다. 값 타입(<{ value: number }>)은 명시적으로 제공하고 이름 리터럴은 인수("counter")에서 추론합니다. 단일 createCapability<T>('name') 호출로는 둘 다 수행할 수 없습니다. T를 명시하면 TypeScript가 이름을 추론하지 못해 string으로 축소되며, 리터럴 이름을 기준으로 하는 컴파일 시점 범위 검사가 무력화됩니다.

반환된 counterCapability는 하이브리드 값입니다.

  • [get, provide]로 구조 분해되며, 훅 내부에서 사용하는 두 접근자입니다.
  • requires / provides에 나열하는 식별자 자체입니다. 별도로 가져올 토큰은 없습니다.

접근자는 다음과 같습니다.

접근자동작
getCounter(ctx)값을 반환합니다. capability가 제공되지 않았으면 예외를 발생시킵니다.
getCounter(ctx, { optional: true })TValue | undefined를 반환합니다. 없을 때 예외를 발생시키지 않습니다.
provideCounter(ctx, value)이 실행에 값을 설정합니다. setup에서 호출합니다.

동일하게 컨텍스트는 ctx.get(capability), ctx.getOptional(capability)ctx.provide(capability, value)를 노출하므로 capability 핸들을 직접 전달할 수 있습니다. 전달한 핸들에 따라 타입이 지정되므로(ctx.get(counterCapability)는 값 타입을 반환합니다) getCounter(ctx)ctx.get(counterCapability)는 서로 바꿔 사용할 수 있습니다. 훅에서 더 읽기 쉬운 방식을 사용합니다.

capability 이름은 애플리케이션 전체에서 고유해야 합니다. 컴파일 시점 범위 검사는 이름 리터럴을 키로 사용하고(런타임에서는 핸들 참조를 키로 사용), 같은 이름을 공유하는 두 capability는 타입 수준 검사에서 하나로 합쳐집니다.

setup

프로비저닝은 전용 setup(ctx) 훅에서 수행됩니다. 나머지 수명 주기가 시작될 때 모든 capability가 준비되도록 배열 순서의 모든 미들웨어에서 어떤 onConfig(init)보다 먼저 실행됩니다. setup은 변경되지 않는 ChatMiddlewareContext(변경 가능한 구성 아님)를 받으며 비동기일 수 있습니다.

requires / provides / optionalRequires

미들웨어의 세 배열 필드는 capability 계약을 선언합니다. 모두 ReadonlyArray<CapabilityHandle>이며 capability 핸들 자체를 나열합니다.

필드의미
provides이 미들웨어가 설정하는 capability입니다. 각 capability는 setup 내부에서 반드시 provide해야 하며, 그렇지 않으면 설정 단계 후 chat()이 예외를 발생시킵니다.
requires이 미들웨어가 읽는 capability입니다. chat()은 이전 미들웨어가 각 capability를 제공하는지 컴파일 시점과 런타임에 검증합니다.
optionalRequires있는 경우 사용하지만 필수는 아닌 capability입니다. 검증 오류를 발생시키지 않으며 getX(ctx, { optional: true })로 읽습니다.

배열 예제

defineChatMiddleware로 미들웨어를 작성하면 requires / provides 튜플 타입이 정교해져 범위 검사와 빌더가 이를 정확히 읽을 수 있습니다. 여기서는 제공자setup에서 카운터를 설정하고 소비자가 훅에서 이를 읽습니다.

import {
chat,
createCapability,
createChatMiddleware,
defineChatMiddleware,
} from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";

const counterCapability = createCapability<{ value: number }>()("counter");
const [getCounter, provideCounter] = counterCapability;

// Provider: declares `provides` and provisions the value in `setup`.
const withCounter = defineChatMiddleware({
name: "with-counter",
provides: [counterCapability],
setup(ctx) {
provideCounter(ctx, { value: 0 });
},
});

// Consumer: declares `requires` and reads the value via `get` in a hook.
const countsChunks = defineChatMiddleware({
name: "counts-chunks",
requires: [counterCapability],
onChunk(ctx) {
getCounter(ctx).value++;
},
onFinish(ctx) {
console.log(`Saw ${getCounter(ctx).value} chunks`);
},
});

const stream = chat({
adapter: openaiText("gpt-5.5"),
messages: [{ role: "user", content: "Hello" }],
// Provider must come before the consumer.
middleware: [withCounter, countsChunks],
});

배열에서 withCounter를 제거하면 chat()은 누락된 "counter" capability를 명시하는 middleware 옵션에서 컴파일 시점 오류를 보고하며, 런타임에는 어댑터가 호출되기 전에 예외를 발생시킵니다.

빌더 예제

createChatMiddleware()은 연결된 .use() 호출을 통해 배열을 구성하고 컴파일 시 provider-before-consumer 순서를 강제합니다. 각 .use()는 미들웨어의 requires가 이전 .use() 호출에서 제공된 기능으로 이미 충족되어 있어야 합니다.

import {
chat,
createCapability,
createChatMiddleware,
defineChatMiddleware,
} from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";

const counterCapability = createCapability<{ value: number }>()("counter");
const [getCounter, provideCounter] = counterCapability;

const withCounter = defineChatMiddleware({
name: "with-counter",
provides: [counterCapability],
setup(ctx) {
provideCounter(ctx, { value: 0 });
},
});

const countsChunks = defineChatMiddleware({
name: "counts-chunks",
requires: [counterCapability],
onChunk(ctx) {
getCounter(ctx).value++;
},
});

const middleware = createChatMiddleware()
.use(withCounter) // provides "counter"
.use(countsChunks) // requires "counter" — OK, already provided above
.build();

const stream = chat({
adapter: openaiText("gpt-5.5"),
messages: [{ role: "user", content: "Hello" }],
middleware,
});

.use() 호출(.use(countsChunks).use(withCounter))의 순서를 바꾸면 제공자보다 소비자가 먼저 배치되므로 빌더가 .use(countsChunks) 줄에서 거부합니다. 따라서 "counter"는 아직 제공된 집합에 없습니다.

검증 보장

capability 시스템은 빠르게 명확한 오류를 발생시킵니다.

  • 컴파일 시점 범위. 아무것도 제공하지 않는 필수 capability는 middleware 옵션에서 타입 오류로 나타납니다. middleware: [...]배열 범위 검사와 순서를 인식하는 createChatMiddleware() 빌더(순서도 추가로 강제)라는 두 방식으로 적용됩니다.
  • 런타임 범위. 타입을 우회하더라도 필수 capability가 없으면 chat()이 범위를 검증하고 어댑터 실행 전에 예외를 발생시킵니다.
  • setup 후 어설션. 미들웨어가 provides에 capability를 선언했지만 setupprovide 접근자를 호출하지 않으면 설정 단계 후 chat()이 예외를 발생시킵니다. 프로비저닝을 조용히 잊을 수 없습니다.
  • 중복 제공 → 마지막 값 우선 및 경고. 두 미들웨어가 같은 capability를 제공하면 마지막 기록이 우선하고 개발 경고가 출력됩니다.
  • 고유 이름. capability name은 애플리케이션 전체에서 고유해야 하며, 컴파일 시점 범위 검사는 이름 리터럴을 키로 사용합니다(런타임에서는 핸들 참조를 키로 사용).

기본 제공 미들웨어

TanStack AI는 도구 결과 캐싱, 스트리밍 텍스트 가리기 및 OpenTelemetry 추적과 같은 일반적인 용도의 미리 준비된 미들웨어를 제공합니다.

미들웨어가져오기동작
toolCacheMiddleware@tanstack/ai/middlewares이름과 인수별 도구 호출 결과를 캐시합니다
contentGuardMiddleware@tanstack/ai/middlewares스트리밍 텍스트 콘텐츠를 가리거나 변환하거나 차단합니다
otelMiddleware@tanstack/ai/middlewares/otelOpenTelemetry 스팬과 GenAI 메트릭을 내보냅니다

각 미들웨어의 전체 옵션과 예제는 기본 제공 미들웨어를 참조하세요. 아래 레시피에서는 직접 만드는 방법을 보여줍니다.

레시피

실시간 사용자 지정 이벤트

onConfig 훅이 대기하는 동안 클라이언트에 진행 상황을 보낼 수 있습니다. 작업이 시작될 때 ctx.emitCustomEvent를 호출하고 완료될 때 다시 호출합니다.

import { type ChatMiddleware } from "@tanstack/ai";

async function prepare() {
await new Promise<void>((resolve) => {
setTimeout(resolve, 1);
});
}

const progress: ChatMiddleware = {
name: "progress",
async onConfig(ctx) {
if (ctx.phase !== "beforeModel") return;
ctx.emitCustomEvent("job:started", { step: "prepare" });
await prepare();
ctx.emitCustomEvent("job:ended", { step: "prepare" });
},
};

엔진은 emitCustomEvent를 호출하는 즉시 각 CUSTOM 청크를 생성합니다. 아직 RUN_STARTED가 전송되지 않았다면 엔진이 먼저 보냅니다. 도구의 emitCustomEvent 호출과 같은 방식으로 클라이언트에서 이 이벤트를 읽습니다. 사용자 지정 이벤트를 참조하세요.

속도 제한

요청당 도구 호출 수를 제한합니다.

import { type ChatMiddleware } from "@tanstack/ai";

function rateLimitMiddleware(maxCalls: number): ChatMiddleware {
let toolCallCount = 0;
return {
name: "rate-limit",
onBeforeToolCall: (ctx, hookCtx) => {
toolCallCount++;
if (toolCallCount > maxCalls) {
return {
type: "abort",
reason: `Rate limit: exceeded ${maxCalls} tool calls`,
};
}
},
};
}

감사 추적

규정 준수를 위해 모든 작업을 기록합니다.

import { type ChatMiddleware } from "@tanstack/ai";
import { db } from "./db";

const auditTrail: ChatMiddleware = {
name: "audit-trail",
onStart: (ctx) => {
ctx.defer(
db.auditLog.create({
requestId: ctx.requestId,
event: "chat_started",
timestamp: Date.now(),
})
);
},
onAfterToolCall: (ctx, info) => {
ctx.defer(
db.auditLog.create({
requestId: ctx.requestId,
event: "tool_executed",
toolName: info.toolName,
success: info.ok,
duration: info.duration,
timestamp: Date.now(),
})
);
},
onFinish: (ctx, info) => {
ctx.defer(
db.auditLog.create({
requestId: ctx.requestId,
event: "chat_finished",
duration: info.duration,
tokens: info.usage?.totalTokens,
timestamp: Date.now(),
})
);
},
};

반복별 도구 교체

에이전트 루프의 단계별로 다른 도구를 노출합니다.

import { type ChatMiddleware } from "@tanstack/ai";

const toolSwapper: ChatMiddleware = {
name: "tool-swapper",
onConfig: (ctx, config) => {
if (ctx.phase !== "beforeModel") return;

if (ctx.iteration === 0) {
// First iteration: only allow search
return {
tools: config.tools.filter((t) => t.name === "search"),
};
}
// Later iterations: allow all tools
},
};

콘텐츠 필터링

청크가 소비자에게 도달하기 전에 삭제하거나 변환합니다.

import { type ChatMiddleware } from "@tanstack/ai";
import { containsProfanity } from "./filters";

const contentFilter: ChatMiddleware = {
name: "content-filter",
onChunk: (ctx, chunk) => {
if (chunk.type === "TEXT_MESSAGE_CONTENT") {
if (containsProfanity(chunk.delta)) {
// Drop the chunk entirely
return null;
}
}
},
};

재시도 로깅을 포함한 오류 복구

import { type ChatMiddleware } from "@tanstack/ai";
import { alertService } from "./services";

const errorRecovery: ChatMiddleware = {
name: "error-recovery",
onError: (ctx, info) => {
ctx.defer(
alertService.send({
level: "error",
message: `Chat ${ctx.requestId} failed after ${info.duration}ms`,
error: String(info.error),
})
);
},
};

TypeScript 타입

핵심 미들웨어 타입은 @tanstack/ai에서 내보냅니다.

import type {
ChatMiddleware,
ChatMiddlewareContext,
ChatMiddlewarePhase,
ChatMiddlewareConfig,
StructuredOutputMiddlewareConfig,
ToolCallHookContext,
BeforeToolCallDecision,
AfterToolCallInfo,
IterationInfo,
ToolPhaseCompleteInfo,
UsageInfo,
FinishInfo,
AbortInfo,
ErrorInfo,
} from "@tanstack/ai";

기본 제공 미들웨어의 옵션 및 타입은 기본 배럴이 아닌 @tanstack/ai/middlewares 서브 경로에서 내보냅니다.

import type {
ToolCacheMiddlewareOptions,
ToolCacheStorage,
ToolCacheEntry,
ContentGuardMiddlewareOptions,
ContentGuardRule,
ContentFilteredInfo,
} from "@tanstack/ai/middlewares";

다음 단계

  • Built-in 미들웨어toolCacheMiddleware, contentGuardMiddleware, otelMiddleware
  • 압축: withCompaction으로 긴 대화를 컨텍스트 제한 안에서 유지합니다
  • OpenTelemetryotelMiddleware를 통해 트레이스와 메트릭을 내보냅니다
  • 도구 — 동형 도구 시스템을 알아봅니다
  • 에이전트 주기 — 다단계 에이전트 루프를 이해합니다
  • 스트리밍 — TanStack AI에서 스트리밍이 작동하는 방식을 알아봅니다