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: 'Unknown station' };
const type = input.type ? String(input.type) : undefined;
return type ? { [type]: station[type] ?? 0 } : station;
},
};
The loop itself is short. Pay attention to the last line in the loop body: while a thread is running, only a single message with the tool results goes out there.
// src/agent-loop.ts
// A lean agent loop: Claude calls tools until the answer is ready.
import type { ContentBlock, ThreadedConversation } from './threaded-conversation.js';
export type ToolHandler = (input: Record<string, unknown>) => Promise<unknown>;
export async function runAgent(
conversation: ThreadedConversation,
userText: string,
handlers: Record<string, ToolHandler>,
maxSteps = 10,
): Promise<string> {
let response = await conversation.send([{ role: 'user', content: userText }]);
for (let step = 0; step < maxSteps; step++) {
if (response.stop_reason === 'pause_turn') {
// A server tool paused: continue without a new message.
response = await conversation.send([]);
continue;
}
if (response.stop_reason !== 'tool_use') {
return textOf(response.content);
}
const results: ContentBlock[] = [];
for (const block of response.content.filter((b) => b.type === 'tool_use')) {
const handler = handlers[String(block.name)];
const output = handler
? await handler(block.input as Record<string, unknown>)
: { error: `Unknown tool: ${String(block.name)}` };
results.push({ type: 'tool_result', tool_use_id: block.id, content: JSON.stringify(output) });
}
// With a thread, only these results go over the wire, not the whole conversation.
response = await conversation.send([{ role: 'user', content: results }]);
}
throw new Error(`Agent stopped after ${maxSteps} steps without a result`);
}
function textOf(content: ContentBlock[]): string {
return content
.filter((b) => b.type === 'text')
.map((b) => String(b.text))
.join('\n');
}
The special case pause_turn shows up with server-side tools such as web search. The documentation says to continue from the paused reply with an empty messages array and not to send a new user message. That is exactly what send([]) does.
Step 4: Test without an API key
The tests replace fetch with a function that returns prepared replies and records every request. This lets you check what is really sent.
// test/threaded-conversation.test.ts
// Checks create, continue and replay with a fake fetch function, without real API calls.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ClaudeApiError, ThreadedConversation } from '../src/threaded-conversation.js';
import { runAgent } from '../src/agent-loop.js';
import { bikeHandlers, bikeTools } from '../src/bike-tools.js';
type Reply = { status: number; body: unknown };
function fakeFetch(replies: Reply[]) {
const calls: { headers: Record<string, string>; body: any }[] = [];
const fn = (async (_url: string, init: RequestInit) => {
calls.push({ headers: init.headers as Record<string, string>, body: JSON.parse(String(init.body)) });
const reply = replies.shift();
if (!reply) throw new Error('No prepared reply left');
return new Response(JSON.stringify(reply.body), { status: reply.status });
}) as unknown as typeof fetch;
return { fn, calls };
}
const ok = (id: string, text: string, stop = 'end_turn'): Reply => ({
status: 200,
body: { id, role: 'assistant', content: [{ type: 'text', text }], stop_reason: stop },
});
const threadError = (status: number, code: string): Reply => ({
status,
body: { type: 'error', error: { type: 'x', message: code, details: { error_code: code } } },
});
function conversation(fn: typeof fetch) {
return new ThreadedConversation({
apiKey: 'test',
model: 'claude-opus-5-5',
maxTokens: 256,
system: 'Answer briefly.',
tools: bikeTools,
fetchFn: fn,
});
}
test('first call creates the thread with system and tools', async () => {
const { fn, calls } = fakeFetch([ok('msg_1', 'Hello!')]);
await conversation(fn).send([{ role: 'user', content: 'Hi' }]);
assert.deepEqual(calls[0].body.thread, { type: 'create' });
assert.equal(calls[0].body.system, 'Answer briefly.');
assert.equal(calls[0].headers['anthropic-beta'], 'message-threads-2026-08-12');
});
test('follow-up call sends only the new message and the previous ID', async () => {
const { fn, calls } = fakeFetch([ok('msg_1', 'Hello!'), ok('msg_2', 'Two hours.')]);
const c = conversation(fn);
await c.send([{ role: 'user', content: 'Hi' }]);
await c.send([{ role: 'user', content: 'How long can I rent?' }]);
assert.deepEqual(calls[1].body.thread, { type: 'continue', previous_message_id: 'msg_1' });
assert.equal(calls[1].body.messages.length, 1);
assert.equal('system' in calls[1].body, false);
assert.equal('tools' in calls[1].body, false);
assert.equal(c.messages.length, 4);
});
test('expired thread is recreated with the full history', async () => {
const { fn, calls } = fakeFetch([
ok('msg_1', 'Hello!'),
threadError(404, 'thread_not_found'),
ok('msg_3', 'Two hours.'),
]);
const c = conversation(fn);
await c.send([{ role: 'user', content: 'Hi' }]);
const res = await c.send([{ role: 'user', content: 'How long can I rent?' }]);
assert.equal(res.id, 'msg_3');
assert.deepEqual(calls[2].body.thread, { type: 'create' });
assert.equal(calls[2].body.messages.length, 3);
assert.equal(calls[2].body.system, 'Answer briefly.');
});
test('if recreating fails too, it continues without a thread', async () => {
const { fn, calls } = fakeFetch([
ok('msg_1', 'Hello!'),
threadError(404, 'thread_not_found'),
threadError(404, 'thread_not_found'),
ok('msg_4', 'Two hours.'),
]);
const c = conversation(fn);
await c.send([{ role: 'user', content: 'Hi' }]);
await c.send([{ role: 'user', content: 'How long?' }]);
assert.equal('thread' in calls[3].body, false);
assert.equal(calls[3].headers['anthropic-beta'], undefined);
});
test('a different 404 (such as a wrong model) is not swallowed', async () => {
const { fn } = fakeFetch([
ok('msg_1', 'Hello!'),
{ status: 404, body: { type: 'error', error: { type: 'not_found_error', message: 'model: x' } } },
]);
const c = conversation(fn);
await c.send([{ role: 'user', content: 'Hi' }]);
await assert.rejects(c.send([{ role: 'user', content: 'And?' }]), ClaudeApiError);
assert.equal(c.messages.length, 2);
});
test('agent loop sends tool results as the only new message', async () => {
const { fn, calls } = fakeFetch([
{
status: 200,
body: {
id: 'msg_1',
role: 'assistant',
stop_reason: 'tool_use',
content: [{ type: 'tool_use', id: 'tu_1', name: 'find_available_bikes', input: { station: 'Hafen', type: 'cargo' } }],
},
},
ok('msg_2', 'There is a free cargo bike at Hafen.'),
]);
const answer = await runAgent(conversation(fn), 'Free cargo bike?', bikeHandlers);
assert.equal(answer, 'There is a free cargo bike at Hafen.');
const sent = calls[1].body.messages;
assert.equal(sent.length, 1);
assert.equal(sent[0].content[0].type, 'tool_result');
assert.equal(sent[0].content[0].content, '{"cargo":1}');
});
npm test
Expected result (shortened):
✔ first call creates the thread with system and tools
✔ follow-up call sends only the new message and the previous ID
✔ expired thread is recreated with the full history
✔ if recreating fails too, it continues without a thread
✔ a different 404 (such as a wrong model) is not swallowed
✔ agent loop sends tool results as the only new message
ℹ tests 6
ℹ pass 6
ℹ fail 0
Step 5: Run it against the real API
Finally, a small start script. For every request it prints whether a thread was created, continued or replayed.
// src/main.ts
// Runs the agent loop against the real Claude API (requires ANTHROPIC_API_KEY).
import { ThreadedConversation } from './threaded-conversation.js';
import { runAgent } from './agent-loop.js';
import { bikeHandlers, bikeTools } from './bike-tools.js';
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
console.error('Please set ANTHROPIC_API_KEY.');
process.exit(1);
}
const conversation = new ThreadedConversation({
apiKey,
model: process.env.CLAUDE_MODEL ?? 'claude-opus-5-5',
maxTokens: 1024,
system: 'You are the booking assistant of the Lindenhof Bike Station. Keep answers short.',
tools: bikeTools,
onRequest: (kind, sent) => console.log(`→ ${kind} (${sent} message(s) sent)`),
});
const answer = await runAgent(
conversation,
'Is there a free cargo bike at Hafen or at Lindenhof right now?',
bikeHandlers,
);
console.log(answer);
console.log(`Own copy: ${conversation.messages.length} messages`);
export ANTHROPIC_API_KEY="your-key"
npm start
In a typical run you first see → create (1 message(s) sent) and then → continue (1 message(s) sent) for each tool result. The exact answer depends on the model.
Which fields stay fixed in a thread
A thread remembers more than the messages. It also stores a fingerprint of certain settings. If you change one of these fields in the middle of a thread, the API replies with HTTP 400 and the code thread_fingerprint_mismatch.
| Group | Fields | Rule |
|---|---|---|
| Create only |
system, tools
|
Leave them out on continue, do not send them as null either |
| Fixed |
model, tool_choice, thinking, mcp_servers, context_management, output_config, top-level cache_control, beta header |
Identical on every request |
| Free |
max_tokens, stop_sequences, stream, metadata, service_tier
|
May change per round |
If you want to change a fixed field, start a new thread. With the class above, that happens when you create a new instance with the existing history. There is one exception for mcp_servers and container: you have to send both on every request because the API stores neither MCP URLs nor tokens.
Combining prompt caching with threads
Threads do not cache automatically. If you want to save money, you set prompt caching yourself as usual. The simplest option is a top-level cache_control, as in the class above. Each round then reads the previous context from the cache and only writes the new round.
There are two rules you should know. A thread request allows at most four cache breakpoints, including the top-level cache_control. In addition, breakpoints you set on system or tools in the create request count again on every continue. So if you already have two breakpoints in your system prompt, you have little room left. When replaying, the documentation says to remove earlier breakpoints from message blocks before sending the conversation again.
Common mistakes and how to avoid them
Sending old messages on continue
The API does not deduplicate. If you send the whole history on continue, messages end up in the thread twice or the request fails. That is why the class above only sends the newMessages array on continue.
Continuing too early while streaming
When streaming, the reply ID already arrives in the message_start event. You may only continue after message_stop, otherwise you get a 404. If a stream breaks off earlier, send the same continue request again. If that returns thread_already_continued or thread_continue_in_flight, a replay helps. The documentation stresses that a replay is always safe.
Carrying on after a refusal
If a reply ends with stop_reason: "refusal", do not continue the thread. Start over, without the refused turn or with a rephrased question. The class therefore removes the turn from its own copy and creates a new thread on the next call.
Expecting compaction inside a thread
Threads are append-only, so they only grow. You cannot push a summary of older rounds into a running thread. If your agent needs to condense its context, condense it into a summary on your side and open a fresh thread from that summary. “Compaction on demand” is not supported with threads either.
Switching the API key
A thread belongs to the organization, the workspace and the key that created it. If your application rotates between several keys, for example to spread load, every switch ends in a replay. Use one fixed key per conversation.
My take
I like Claude message threads because the feature is built honestly. Anthropic promises a short-lived accelerator with clear rules and no permanent conversation store. That fits the way I would build agents anyway: the state belongs to the application, and the service may only cache it.
I see two things critically. One is the added complexity. A replay path that rarely runs is a path that will break one day without anyone noticing. That is why I would cover it with tests that force exactly this case, as in the example. The other is data protection. For many teams it is no problem that threads are not ZDR eligible, but it should be a conscious decision and not get lost in the fine print.
For chats with people, I would not introduce threads first. Whoever goes to get a coffee breaks the five minutes, and then the replay pays back the savings. Agents that call tools every few seconds are a different story. That is where the gain is largest and the risk is smallest.
FAQ
How long does a Claude message thread last?
A thread expires five minutes after the last request. Each stored reply restarts the five minutes. The API deletes it no later than one hour after its last use.
Are message threads cheaper than normal requests?
No. According to Anthropic, prices and rate limits do not change. You save transfer volume and get the first token sooner; you still lower costs with prompt caching.
Do message threads work with Amazon Bedrock?
The documentation does not list Bedrock as a platform. The beta is available on the Claude API, Claude Platform on AWS, Google Cloud (Anthropic managed) and Microsoft Foundry.
Can I read or delete a thread?
No, there is no API for that. That is why your own copy of the conversation is mandatory: it is the only way to replay an expired thread.
Your next step
Take your existing agent loop and count how many messages go out per request. If the number grows with every step and the steps follow each other quickly, try the class from this article in a test environment and write the test for the replay case first. I am curious: where would you use threads, and where does the missing ZDR status hold you back?
Sources
- Claude Docs: Message threads (beta)
- Claude Docs: Release notes (entry from November 2, 2026)
- Claude Docs: Prompt caching
- Claude Docs: Streaming
- Node.js: Test runner
This post was created with AI assistance.
👉 Next step: New here? My start page collects the best articles, tools and learning paths in one place.
Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

Top comments (0)