The Claude message threads API solves a problem that anyone who has built an agent loop knows well: every tool call makes the request bigger. The Messages API is stateless, so each step sends the entire conversation again, including the system prompt and the tool definitions. After twenty steps, a hefty chunk of JSON crosses the wire every time just so Claude can read one new line.
Since November 2, 2026, there is a beta for this. The API holds on to the conversation for a short while, and you only send the new messages. I am mostly interested in it for agents that take many small tool steps. At the same time, the feature has a trap: if you do not build the way back for expired threads from day one, your application simply fails after a five-minute pause.
In short
With message threads, the Claude API keeps a conversation for up to five minutes after the last request, so follow-up requests only contain the new messages. Requests get smaller and the first token arrives sooner, but prices stay the same. The biggest pitfall: a thread can disappear at any time, so you need your own copy of the conversation and a replay path.
What you will learn in this article
- How create, continue and replay work together in message threads
- When threads pay off and when to skip them (data retention, Bedrock)
- A tested TypeScript class that uses threads with automatic replay
- An agent loop with a tool that only sends tool results
- Which fields must not change inside a thread and common error codes
What is the Claude message threads API?
Normally the Messages API forgets everything after each response. That is deliberate and has advantages: you keep full control over the history, you can shorten or rewrite messages, and you do not depend on state on the server. The price is that every request carries the entire context.
A thread flips this for a moment. On the first request you send everything as usual and add "thread": {"type": "create"}. The API keeps the conversation together with Claude's answer and files it under that answer's ID. On the next step you only send the new messages and point to the last reply with previous_message_id. According to Anthropic, this lowers the time to first token (the time until the first token of the reply arrives) and keeps it steadier in long conversations.
Create, continue and replay
There are three situations you need to know:
- Create: the first request with the full conversation,
systemandtools. - Continue: a follow-up request with the new messages and the ID of the previous reply. You leave out
systemandtools. - Replay: the thread is gone. You send the full conversation from your own copy again as a create and carry on with the new ID.
Plan for the third case, because it is part of the design. A thread expires five minutes after the last request and is deleted no later than one hour after its last use. Anthropic explicitly describes threads as “best-effort”, so there is no guarantee. Your application remains the source of truth.
🎬 Create, continue and replay with Claude message threads – this animated diagram only runs on my site: watch it on sascha-ms.de
When does the Claude message threads API pay off?
Threads help most when a conversation has many short rounds with small pauses in between. A typical case is an agent loop: Claude calls a tool, your code returns the result, Claude calls the next tool. Each round adds only a few blocks, and the context before it stays the same.
Schematic: without a thread every request grows with the history, with a thread only the new part is sent.
According to the documentation, prices and rate limits do not change. So threads save you transfer volume and latency, while token costs stay the same. For costs, prompt caching remains the tool of choice; more on that below.
| Situation | Use threads? | Why |
|---|---|---|
| Agent loop with many tool steps | Yes | Many rounds in quick succession, growing context |
| Chat where people type | Sometimes | Pauses over five minutes lead to a replay |
| Single request without follow-ups | No | There is nothing to continue |
| Message Batches or token counting | No | Both are rejected with thread |
| Data with ZDR or HIPAA requirements | No | Threads are neither ZDR nor HIPAA eligible |
| Claude via Amazon Bedrock | No | Bedrock is not listed under availability |
The data retention point deserves a second look. ZDR stands for zero data retention, the promise that requests are not stored. According to Anthropic, threads are held encrypted in memory, never written to durable storage, and deleted within one hour at the latest. That is fine for many applications. If your company has ZDR in its contract, though, check with the people responsible first.
The beta is available on the Claude API, Claude Platform on AWS, Google Cloud (Anthropic managed) and Microsoft Foundry. If you use Claude via Amazon Bedrock, you will have to do without it for now. For agents on Bedrock, a clean token budget for AI agents is the better place to start.
Step by step: Claude message threads in TypeScript
Now I will build a small class that uses threads and repairs itself when a thread disappears. On top of that comes an agent loop with a tool for a fictional bike rental, the “Lindenhof Bike Station”. I deliberately use direct HTTP calls via fetch instead of the SDK, so you can see exactly what goes over the wire.
Prerequisites
- Node.js 22 or newer (tested with 22.23) and npm
- TypeScript 7 (tested with 7.0.2)
- For the real run, a Claude API key in
ANTHROPIC_API_KEY - The tests run without a key and without network access
The API version in the header is 2026-10-21. I described what changed with this version, such as the removal of temperature, in Claude API 2026-10-21: migrating to the new SDK.
Step 1: Create the project
mkdir claude-message-threads && cd claude-message-threads mkdir src test npm init -y npm install --save-dev typescript@^7.0.2 @types/node@^24 Then replace the package.json and create the tsconfig.json:
{ "name": "claude-message-threads", "version": "1.0.0", "private": true, "type": "module", "scripts": { "build": "tsc -p tsconfig.json", "test": "npm run build && node --test dist/test/*.test.js", "start": "npm run build && node dist/src/main.js" }, "devDependencies": { "@types/node": "^24.0.0", "typescript": "^7.0.2" } } { "compilerOptions": { "target": "ES2023", "module": "NodeNext", "moduleResolution": "NodeNext", "outDir": "dist", "rootDir": ".", "strict": true, "skipLibCheck": true, "types": ["node"] }, "include": ["src", "test"] } Step 2: The ThreadedConversation class
The core keeps its own copy of the conversation and decides on every call whether to create, continue or replay a thread. Read the code once calmly; the comments explain the places where the documentation sets clear rules.
// src/threaded-conversation.ts // Keeps a conversation with Claude in a message thread (beta) and replays it when needed. export type Role = 'user' | 'assistant'; export type ContentBlock = { type: string; [key: string]: unknown }; export interface Message { role: Role; content: string | ContentBlock[]; } export interface ToolDefinition { name: string; description: string; input_schema: Record<string, unknown>; } export interface ClaudeResponse { id: string; role: 'assistant'; content: ContentBlock[]; stop_reason: string | null; usage?: Record<string, unknown>; } export type RequestKind = 'create' | 'continue' | 'replay' | 'unthreaded'; export interface ThreadOptions { apiKey: string; model: string; maxTokens: number; system?: string; tools?: ToolDefinition[]; baseUrl?: string; fetchFn?: typeof fetch; onRequest?: (kind: RequestKind, messagesSent: number) => void; } const API_VERSION = '2026-10-21'; const THREADS_BETA = 'message-threads-2026-08-12'; export class ClaudeApiError extends Error { constructor( readonly status: number, readonly errorCode: string | undefined, message: string, readonly unsupported: string[] = [], ) { super(message); this.name = 'ClaudeApiError'; } } // These errors mean the thread is gone or cannot be continued: replay it. const REPLAY_CODES = new Set([ 'thread_not_found', 'thread_already_continued', 'thread_continue_in_flight', ]); export class ThreadedConversation { private readonly history: Message[] = []; private previousId: string | undefined; private threadsDisabled = false; private readonly fetchFn: typeof fetch; constructor(private readonly options: ThreadOptions) { this.fetchFn = options.fetchFn ?? fetch; } /** Your own complete copy of the conversation: the source of truth. */ get messages(): readonly Message[] { return this.history; } /** Sends new messages. An empty array continues a paused response (pause_turn). */ async send(newMessages: Message[]): Promise<ClaudeResponse> { this.history.push(...newMessages); let response: ClaudeResponse; try { response = await this.request(newMessages); } catch (error) { // Failed request: roll the copy back to the last good state. this.history.splice(this.history.length - newMessages.length, newMessages.length); throw error; } if (response.stop_reason === 'refusal') { // Do not continue a refused turn: drop it from the copy, the next call starts over. this.history.splice(this.history.length - newMessages.length, newMessages.length); this.previousId = undefined; return response; } this.appendAssistant(response.content); this.previousId = this.threadsDisabled ? undefined : response.id; return response; } private async request(newMessages: Message[]): Promise<ClaudeResponse> { if (this.threadsDisabled) { return this.post('unthreaded', this.history); } if (!this.previousId) { return this.createOrFallBack('create'); } try { return await this.post('continue', newMessages); } catch (error) { if (error instanceof ClaudeApiError && error.errorCode && REPLAY_CODES.has(error.errorCode)) { return this.createOrFallBack('replay'); } throw error; } } private async createOrFallBack(kind: 'create' | 'replay'): Promise<ClaudeResponse> { try { return await this.post(kind, this.history); } catch (error) { const tooBig = kind === 'replay' && error instanceof ClaudeApiError && error.errorCode === 'thread_not_found'; const unsupported = error instanceof ClaudeApiError && error.errorCode === 'thread_unsupported_request'; if (tooBig || unsupported) { // Threads speed things up but are optional: carry on without a thread. this.threadsDisabled = true; return this.post('unthreaded', this.history); } throw error; } } private appendAssistant(content: ContentBlock[]): void { const last = this.history.at(-1); if (last?.role === 'assistant' && Array.isArray(last.content)) { // Continuation after pause_turn: append to the existing reply. last.content.push(...content); return; } this.history.push({ role: 'assistant', content: [...content] }); } private buildBody(kind: RequestKind, messages: readonly Message[]): Record<string, unknown> { const { model, maxTokens, system, tools } = this.options; const body: Record<string, unknown> = { model, max_tokens: maxTokens, cache_control: { type: 'ephemeral' }, messages, }; if (kind !== 'continue') { if (system) body.system = system; if (tools?.length) body.tools = tools; } if (kind === 'create' || kind === 'replay') { body.thread = { type: 'create' }; } if (kind === 'continue') { body.thread = { type: 'continue', previous_message_id: this.previousId }; } return body; } private async post(kind: RequestKind, messages: readonly Message[]): Promise<ClaudeResponse> { this.options.onRequest?.(kind, messages.length); const headers: Record<string, string> = { 'content-type': 'application/json', 'x-api-key': this.options.apiKey, 'anthropic-version': API_VERSION, }; if (kind !== 'unthreaded') headers['anthropic-beta'] = THREADS_BETA; const res = await this.fetchFn(`${this.options.baseUrl ?? 'https://api.anthropic.com'}/v1/messages`, { method: 'POST', headers, body: JSON.stringify(this.buildBody(kind, messages)), }); const json = (await res.json()) as Record<string, any>; if (!res.ok) { const err = json?.error ?? {}; throw new ClaudeApiError(res.status, err.details?.error_code, err.message ?? `HTTP ${res.status}`, err.details?.unsupported ?? []); } return json as ClaudeResponse; } } Three decisions are built in. First, send() adds the new messages to its own copy and removes them again if the request fails. Second, only an error code from error.details.error_code triggers a replay. A normal 404 caused by a wrong model name therefore does not end up in an endless loop. Third, the class switches to requests without a thread if recreating fails as well or if the organization does not support threads. The application gets a little slower then, but it keeps working.
Step 3: Agent loop with a tool
Now for the tool. The bike rental has two stations and three bike types; for simplicity the data lives in the code.
// src/bike-tools.ts // Fictional bike rental "Lindenhof Bike Station" as an example tool. import type { ToolDefinition } from './threaded-conversation.js'; import type { ToolHandler } from './agent-loop.js'; const STOCK: Record<string, Record<string, number>> = { Lindenhof: { citybike: 4, ebike: 1, cargo: 0 }, Hafen: { citybike: 2, ebike: 3, cargo: 1 }, }; export const bikeTools: ToolDefinition[] = [ { name: 'find_available_bikes', description: 'Returns the available bikes at a station, optionally filtered by type.', input_schema: { type: 'object', properties: { station: { type: 'string', enum: Object.keys(STOCK) }, type: { type: 'string', enum: ['citybike', 'ebike', 'cargo'] }, }, required: ['station'], }, }, ]; export const bikeHandlers: Record<string, ToolHandler> = { async find_available_bikes(input) { const station = STOCK[String(input.station)]; if (!station) return { error: 'Source:https://dev.to/sascha-ms/claude-message-threads-api-agent-loops-in-typescript-68oRelated posts
How to charge by square foot or square meter on Wix Stores
One lot, one URL, one price: how a plot in a browser city is modelled
auth-verify: JWT, OTP, Passkeys and More in One Node.js Library

