◉CLAUDE LABJP
●THREADS — Message threads arrived in beta on the Messages API (11/2): the server keeps the conversation and you send only new messages●API — The Messages API maximum request size grew from 32 MB to 128 MB (10/28)●SONNET 4.5 — Retires on the Claude API on 11/30, 26 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST — Opus 4.8 fast mode is deprecated and will be removed on 11/30. Migrate to Opus 5.5●NEW — I Put a Ten-Line Promise File at the To…●THREADS — Message threads arrived in beta on the Messages API (11/2): the server keeps the conversation and you send only new messages●API — The Messages API maximum request size grew from 32 MB to 128 MB (10/28)●SONNET 4.5 — Retires on the Claude API on 11/30, 26 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST — Opus 4.8 fast mode is deprecated and will be removed on 11/30. Migrate to Opus 5.5●NEW — I Put a Ten-Line Promise File at the To…
Articles/API & SDK
⬡ API & SDK/2026-11-05Intermediate

Three Changes I Made Before Moving to Message Threads, So My Own Copy of the Conversation Stays Mine

What changes when a loop that resends the full history every turn moves to message threads, which entered beta on November 2. How create and continue differ, how to triage a 404 or 400, and where I would not migrate, all based on the official docs.

Claude API143message threadsMessages APIbetamigration14

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 system and tools, and add {"type": "create"} as thread.
  • 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

  1. 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.
  2. Sort the fields into three groups: create-only, fixed on every request, and free to change.
  3. Split errors into "safe to replay" and "fix your code."

The sorting table, rearranged from the docs in my own words:

GroupFieldsRule
Create-onlysystem, toolsSending them on a continue request (even as null) returns a 400
Fixedmodel, tool_choice, thinking, mcp_servers, context_management, output_config, top-level cache_control, the set of beta headersSame values as create on every request; a difference returns a 400 with thread_fingerprint_mismatch
Freemax_tokens, stop_sequences, stream, metadata, service_tierChange 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 resp

Three 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:

  1. Turn one goes out as create, including system.
  2. 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.
  3. After the replay, the next turn goes back to continue from the new id.
  4. thread_fingerprint_mismatch surfaces as an exception, and after a refusal the prev_id is 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.

Statuserror_codeFirst move
404thread_not_foundReplay from your copy with create. A nonexistent model name also returns a 404, so read error_code first
400thread_fingerprint_mismatchRead the field names in the message and align the fixed fields; to change one, start a new thread
400thread_already_continuedEach id can be continued once. Continue from the latest id, or replay if you never received it
400thread_continue_in_flightUsually your own earlier attempt is still running. Wait for it, or replay
400thread_unsupported_requestRemove 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.

Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

⬡ API & SDK2026-10-31
Days Vanished From My Daily Table After Moving to the Usage and Cost Analytics API — Why Empty Days Don't Come Back, and How to Zero-Fill
The Usage and Cost Analytics API (beta since 10/29) returns no rows for days with no usage. Here is why a drop-in swap from the legacy report leaves gaps, the order I'd check things in, and a small Python zero-fill, tested against mock data.
⬡ API & SDK2026-10-27
Three Places Python SDK 2.0.0 Breaks Your Code — A Checking Order Built on a Mocked Transport
Upgrading to Python SDK 2.0.0 changes behavior in three places: the temperature argument, the auth header, and double credentials. Here are the outputs I reproduced on a mocked transport, the fix for proxy setups, and the order I check in.
⬡ API & SDK2026-10-20
Moving Structured Outputs to output_config.format — What to Check Before You Drop the Beta Header
A checklist for moving structured outputs from output_format and the beta header to output_config.format, drawn from the official docs: the Python SDK TypeError, prompt-cache invalidation, schema limits, and the cases where output won't match your schema.