When you write long conversation loops, there is a night when the size of each request in the log starts to bother you. Every turn, the whole history goes out again from the top. As an indie developer who has written plenty of small chat loops, I know that feeling well. When I saw the one-line entry for message threads in the November 2 release notes, it took me three passes through the docs before the idea settled.
This post is a record of that reading. It covers what I changed when thinking about moving a resend-everything loop to threads, and what I deliberately left alone.
What I haven't verified
Everything here follows the official documentation. Threads are in beta (beta header message-threads-2026-08-12), and I haven't used them in a production loop myself yet.
The only thing I ran is the replay logic further down. I ran it against a fake client that returns 404s and 400s, to confirm that create and continue are called in the order I expected. I haven't measured real response times, and I haven't seen how the errors look in production.
What changes
Until now, each turn put the whole conversation into messages. With threads, you send the full conversation once, then only the new messages and the id of the previous response.
- Create: send the full conversation with
systemandtools, and add{"type": "create"}asthread. - Continue: send only the new messages and add
{"type": "continue", "previous_message_id": "..."}. - Replay: a thread expires 5 minutes after its last request and is deleted within 1 hour at most. If it can't be continued, a continue request returns a 404.
The part worth underlining is that a thread is best-effort. You can't tell on the spot whether a turn was stored. You find out on the next continue.
The three changes
- Keep your own copy of the conversation. If you drop the history because the API remembers it, you have nothing to rebuild from when the 404 arrives.
- Sort the fields into three groups: create-only, fixed on every request, and free to change.
- Split errors into "safe to replay" and "fix your code."
The sorting table, rearranged from the docs in my own words:
| Group | Fields | Rule |
|---|---|---|
| Create-only | system, tools | Sending them on a continue request (even as null) returns a 400 |
| Fixed | model, tool_choice, thinking, mcp_servers, context_management, output_config, top-level cache_control, the set of beta headers | Same values as create on every request; a difference returns a 400 with thread_fingerprint_mismatch |
| Free | max_tokens, stop_sequences, stream, metadata, service_tier | Change per turn as you like |
I keep the fixed fields in one place in the code, and both create and continue read from it. Written in two places, one copy eventually gets edited and the other doesn't, and that is how you walk into a 400.
A wrapper that replays
The problem it solves: when a continue request is refused, resend from your own copy with a create request.
import anthropic
BETA = ["message-threads-2026-08-12"]
REPLAY_CODES = {"thread_not_found", "thread_already_continued", "thread_continue_in_flight"}
def error_code(exc):
"""Pull error_code out of the SDK exception body. The body shape differs by version, so check both."""
body = getattr(exc, "body", None)
if not isinstance(body, dict):
return None
err = body.get("error", body)
return (err.get("details") or {}).get("error_code")
class ThreadChat:
def __init__(self, client, *, model, system, tools=None, max_tokens=1024):
self.client = client
# Fixed fields live in one place; create and continue send the same values
self.fixed = dict(model=model, betas=BETA, cache_control={"type": "ephemeral"})
self.max_tokens = max_tokens
self.system = system
self.tools = tools
self.history = [] # Source of truth. Keep it yourself, apart from the API-side thread
self.prev_id = None # None means the next turn goes out as create
self.replays = 0 # How many times we started over, so we can count later
def _create(self, messages):
kw = dict(self.fixed, max_tokens=self.max_tokens, system=self.system,
messages=messages, thread={"type": "create"})
if self.tools:
kw["tools"] = self.tools
return self.client.beta.messages.create(**kw)
def _continue(self, new_messages):
# Do not pass system or tools here (that returns a 400)
return self.client.beta.messages.create(
**self.fixed, max_tokens=self.max_tokens, messages=new_messages,
thread={"type": "continue", "previous_message_id": self.prev_id})
def send(self, text):
new = {"role": "user", "content": text}
if self.prev_id is None:
resp = self._create(self.history + [new])
else:
try:
resp = self._continue([new])
except (anthropic.NotFoundError, anthropic.BadRequestError) as exc:
if error_code(exc) not in REPLAY_CODES:
raise # A fingerprint mismatch should be fixed, not hidden by a replay
self.replays += 1
resp = self._create(self.history + [new])
if resp.stop_reason == "refusal":
self.prev_id = None # Do not continue after a refusal; keep this turn out of the copy too
return resp
self.history += [new, {"role": "assistant", "content": resp.content}]
# The API does not store a response written by a fallback model, so go back to create
iters = getattr(getattr(resp, "usage", None), "iterations", None) or []
fell_back = any(getattr(i, "type", None) == "fallback_message" for i in iters)
self.prev_id = None if fell_back else resp.id
return respThree reasons for writing it this way.
First, thread_fingerprint_mismatch is not in REPLAY_CODES. A replay might make the request go through, but it would only hide the mismatch. It means a value is drifting somewhere in my code, so I let the exception travel upward.
Second, I count replays. Replaying is safe, but if it happens on every turn, the migration has lost its point. The counter is there so I can tally a day's worth later.
Third, the fallback_message check comes from the docs' description (an entry in usage.iterations means a fallback model wrote the response). I'd suggest printing the attribute shape once with your SDK version before relying on it.
What I checked against a fake client
Against a fake client that returns 404s and 400s, these four things behaved as expected:
- Turn one goes out as create, including
system. - When turn two gets a 404 with
thread_not_found, the same input is sent again as create with the three messages from my own copy. - After the replay, the next turn goes back to continue from the new
id. thread_fingerprint_mismatchsurfaces as an exception, and after arefusaltheprev_idis cleared so the next turn is a create.
This isn't a verification of the real API. It only checks that the code branches in the order I understood from the docs.
Triage when an error comes back
These errors are returned before the model runs, use no tokens, and leave the thread's expiry unchanged. I reordered the docs' table by what to do first.
| Status | error_code | First move |
|---|---|---|
| 404 | thread_not_found | Replay from your copy with create. A nonexistent model name also returns a 404, so read error_code first |
| 400 | thread_fingerprint_mismatch | Read the field names in the message and align the fixed fields; to change one, start a new thread |
| 400 | thread_already_continued | Each id can be continued once. Continue from the latest id, or replay if you never received it |
| 400 | thread_continue_in_flight | Usually your own earlier attempt is still running. Wait for it, or replay |
| 400 | thread_unsupported_request | Remove the unsupported feature, or send the full conversation without thread |
A 404 has no single cause. It can be expiry, a turn the API never stored (such as a tool-call response cut off by max_tokens), or a request from a different key or workspace. Rather than guessing which, I'd replay and keep one key and workspace per thread.
Where I would not migrate
The docs are explicit about what threads don't support.
- Message Batches and token counting.
- Compaction, including compaction on demand. For a long conversation you write the summary yourself and start a new create.
- Context editing, apart from one narrow exception.
- ZDR and HIPAA. If your organization has HIPAA readiness enabled, you get
thread_unsupported_request. - There is no API to list, read, or delete a thread.
I would leave short conversations of a few turns, and anything that runs through batches, on the resend-everything path. I'd start with agent loops that run past ten turns.
Next step
Keep the conversation's source of truth in your own hands, and borrow the thread as a faster copy of it. If you hold that line, a 404 won't throw you.
Rather than rewriting everything, pick one long loop, wrap it in a thin class like ThreadChat, and count a day's replays. If replays are rare, widening the scope from there is enough.