Continue a conversation¶
Enable the threads capability to store conversation history and continue it across requests. Each
new turn only needs the new input.
Without threads, calls are stateless. A request using previous_response_id, conversation or
thread_id returns 404, and so does GET /responses/{id}. The message says why:
This key does not hold the `threads` capability, so no response or thread was kept for it to
continue or read back. Send the whole conversation in the request, or use a key that holds
`threads`.
A key that holds threads and names an id that does not exist gets a plain Not Found.
Continue from a response¶
Pass the previous response id with your next message:
curl -s -X POST "$CORE_URL/responses" \
-H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \
-d '{"model": "<a model id>", "previous_response_id": "resp_0KQ2mR...",
"input": "And in one sentence?"}'
For the first stored call, omit both previous_response_id and thread_id. Core creates a thread
and returns its id in the 201 response. Use the returned response id to continue the conversation.
You can also use thread_id to select a thread directly. Send only one of these fields per request.
A tool result you send on a continued conversation must answer a call the thread already holds or the same input replays; one that answers neither is refused (Client-executed tools).
Continue a conversation on /messages¶
Anthropic's Messages request has no thread field, and a vendor SDK client cannot add one to the
body without extra_body. POST /messages therefore reads the thread from the X-Core-Thread-Id
request header too, the same header every stored answer carries back. Read it off the first answer
and set it as a default header on the client:
first = client.messages.with_raw_response.create(...)
thread = first.headers["X-Core-Thread-Id"]
client = anthropic.Anthropic(default_headers={"X-Core-Thread-Id": thread}, ...)
The header means exactly what thread_id in the body means. Sending both with different values is
refused with a 400; the same value in both is fine.
Storage settings¶
| Setting | Result |
|---|---|
threads enabled, default store |
201 with a Location header; the turn is stored. |
store: false |
200; the turn is not stored and cannot be continued by id. |
threads disabled |
200; the call is always stateless. |
Use store: false for tasks whose input and output you do not need to retrieve, such as
classification or rewriting. Usage is still metered.
Read stored history¶
| Endpoint | Result |
|---|---|
GET /responses/{response_id} |
A stored turn and its output items |
GET /threads |
Threads, with cursor pagination |
GET /threads/{thread_id} |
Thread details |
GET /threads/{thread_id}/items |
Messages in order |
POST /threads/{thread_id}/items |
Append items without calling a model |
GET /thread-items?q=refund |
Messages that hold words, across threads |
PUT /threads/{thread_id}/items/{item_id}/metadata |
A person's note on a message |
DELETE /threads/{thread_id} |
Delete the thread and its messages |
Every route that answers a thread (POST /threads, GET /threads, GET /threads/{thread_id},
PATCH /threads/{thread_id}) carries two fields a chat list needs without reading the items:
subjectis the person the thread is for. A key sees it on every thread; a subject token sees its own id on her threads andnullon every thread that is not hers, shared or another person's, exactly as a search hit names it.message_countsis how manymessageitems the thread holds, by who wrote them:{"user": 2, "assistant": 1, "developer": 0}. A tool call, its result, a reasoning block and a refusal are not counted. The counts are made when you read the thread, in one statement for the whole page of a list.
Each item a turn wrote carries a response_id, the id of the response that produced it, so a chat
history can open the trace of any answer with that id; an item
you appended yourself has "response_id": null. Each item also carries subject, the person the
request that wrote it was for -- who said it, in a thread several people write
in -- and null when that request named nobody. Everyone who may
read the thread sees it.
Individual items cannot be edited or deleted after they are added. An item that carries an
input_audio recording is refused 422 on items, on POST /threads and
POST /threads/{thread_id}/items alike: only a turn transcribes a recording, so send it through
POST /responses (Transcribe audio).
Search messages¶
Find the messages that say something, across every thread your key may read:
curl -s "$CORE_URL/thread-items?q=refund" -H "Authorization: Bearer $CORE_KEY"
Each hit names its thread_id and item_id, who wrote it (role), when, and a snippet of about
thirty words with the start and end of every match under highlights, counted in UTF-16
code units as a JavaScript string is. Fetch the whole message
with GET /threads/{thread_id}/items/{item_id}. Write q as you would in a search engine: whole
words, "a quoted phrase", or between alternatives and -word to leave one out. Narrow it with
thread_id, thread_metadata=key:value, role (user, assistant or developer), and from
and to.
Only messages and refusals are searched, never tool calls, tool results or Core's own notices, and
only the threads GET /threads lists for the same request: when your request names a person, she
finds her own threads and the shared ones. A message that a key with
pseudonymisation hid from the model is found by the real name,
because the thread keeps what was written.
Results come newest first. A page reads a fixed share of your history, so it can come back short or
empty and still carry a next_cursor; searched_to then says the day it searched back to. Pass the
cursor to search further back, or narrow from and to.
Notes on a message¶
Keep your application's own note on one message, such as a thumbs up, with
PUT /threads/{thread_id}/items/{item_id}/metadata:
curl -s -X PUT "$CORE_URL/threads/$THREAD/items/$ITEM/metadata" \
-H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \
-H 'X-Core-Subject: {"id": "anna"}' \
-d '{"metadata": {"score": "negative", "stars": 2, "liked": false}}'
A note holds up to 16 keys, each a string of at most 512 characters, a number or a boolean; Core
never reads it. Each person keeps her own note on a message, the person your request names, and a
request that names nobody keeps the organisation's own beside them, so two people who both rate an
answer never overwrite each other. Writing a note needs only that the request may read the
thread. PUT replaces the person's whole note, DELETE removes it, and GET lists the notes on
the message; a subject token sees and writes only its own.
Find the messages one person marked with GET /thread-items?metadata=score:negative&metadata_by=anna,
alone or beside q. A value matches the string and the number or boolean it spells, so
stars:2 finds 2 and "2". Each hit carries the note that matched as metadata. Erasing a
person takes every note she wrote; a person you write inside a note the organisation keeps
({"by": "anna"}) is yours to remove.
Several people in one thread¶
A thread belongs to the person who opened it, or to nobody. A group thread is a thread your
rules open to more people than that, available only where your deployment switches it on
(threads_group_reach_by_rule, off by default); every item's author and the order messages
arrive in, below, hold everywhere. To open one, write one rule on your key that opens a thread
to the people a label lists, and label the thread with them.
{"id": "thread-participants", "effect": "permit", "kind": "thread",
"actions": ["see", "write"],
"when": [{"atom": "label_is_subject", "label": "participants"}]}
With "actions": ["see", "write"] the people listed read and write the thread but cannot delete
it; deleting it takes a rule that names delete. A key's policy document is exactly the rules
written in it, so add this rule beside the ones it already holds: read the document with
GET /key, add the rule to rules, and send the whole document back
(Write your first rule shows the
request). Then Anna's device opens a thread for her and Bob:
curl -s -X POST "$CORE_URL/threads" \
-H "Authorization: Bearer $ANNA_TOKEN" -H "Content-Type: application/json" \
-d '{"labels": {"participants": ["anna", "bob"]}}'
Bob's own subject token now lists and
reads that thread, appends to it, continues it with a turn (thread_id or the
previous_response_id of any answer on it), changes its labels within what his rules let him
write, and hears its events. Who may be added to participants is what
writable_labels allows each
person to write; without it, anyone who may write the thread may list anyone. Take Bob off
participants and all of that stops at his next request. A shared thread -- one your backend
opened for nobody -- is read-only to a device unless the rule names its person on it too: label it
with participants and the people listed write in it like in any other group thread, while the
people not listed only read it.
Who said what. Every item names its author in subject, so a chat window can put each message
beside the right person. Bob's turn on Anna's thread is Bob's: its answer is on his usage, and he
reads it with GET /responses/{id}; the thread itself stays Anna's.
Two messages at the same moment. Every message takes its place when it arrives. Core writes a
turn's input to the thread just before it calls the model, and the answer when the model is done.
When Bob writes while Anna's answer is still being made, the thread reads: Anna's message, Bob's
message, Bob's answer, Anna's answer -- the answers in the order they finished. Bob's turn sees
Anna's message, not her unfinished answer; Anna's turn does not see Bob's message. Neither request
is refused. A request with an idempotency key writes its message and its answer together when the
turn ends, so the others see that message only then. Three ends leave a message on the thread
whose response_id names no response, so GET /responses/{id} answers 404 for it: Core
stopping mid-turn, an unexpected error inside Core, and a failure to write the answer down.
Traces. Each person reads the traces of the others' turns on the thread.
Thread metadata¶
Use metadata to attach up to 16 string key/value pairs to a thread:
curl -s -X POST "$CORE_URL/threads" \
-H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \
-d '{"metadata": {"case": "4711", "channel": "support"}}'
Filter threads with GET /threads?metadata=case:4711. Repeat the metadata query parameter to
filter on several pairs; every pair must match. Metadata is stored and returned as supplied, and it
is never sent to the model as instructions.
Metadata never decides who reaches a thread; labels do. Pass labels beside metadata, such as
{"labels": {"department": "payroll"}}, and when your key names the person a request is for, a
rule of kind thread in your key's or your organisation's policy can admit everyone in payroll.
Without such a rule, labels change nothing about access. For one release a rule still reads a
thread's metadata under a name its labels lack
(Labels and metadata). See
Labels on everything. The responses in a
labelled thread, and the memory records, files and tasks its turns create, start with its labels
(Labels and metadata).
When a request names a person, your policy can also limit which values she may write under a
label, so she cannot label a thread into a project she is not in
(Which labels a person may write).
A rule of kind thread can also name its actions: a
permit for see alone lets a project's members read a thread without appending to it, relabelling
it or deleting it.
Delete a thread¶
DELETE /threads/{thread_id} removes the conversation content. Subsequent requests to GET
/responses/{id} for its responses return 404. Core retains response ids, statuses and usage
records, so deleting a conversation does not remove its recorded usage.
Conversation compaction¶
If your key has the compaction capability, Core shortens what a long conversation sends the
model. It counts turns: a turn is one request with everything Core added to it, the model's
answer and the calls and results of the tools Core runs included, and it is never split. A tool
result you send back yourself starts the next turn, as does a user message you append. The last
keep_recent_turns turns (five unless you set it) always go in full. What lies before them is cut
into segments of segment_turns whole turns (three), and each segment is replaced by one summary;
with the defaults, the first summary is made at the eighth turn. In tiered mode
the oldest segments are grouped again, so the further back a stretch of the conversation is, the
shorter it reads. A conversation of a single turn, however many tool calls it made, is therefore
never summarised. In window mode nothing is summarised and only the recent turns are sent.
The original messages are never touched and stay available through GET /threads/{id}/items;
compaction changes only what the model is given. A summary is made by a background job just after
a turn, so most conversations already have theirs by the time the next request arrives; when one
is not ready in time, Core sends those items in full instead of dropping them — a turn never loses
information because a summary was missing.
GET /threads/{thread_id}/summaries lists what Core made for a thread: which positions each
summary covers, which tier it sits at, which model wrote it and what it says.
The counts are turns, not tokens, so they do not guarantee that every conversation fits a model’s
context window. The settings named keep_recent and segment_items counted items; a request that
still sends them is refused with 422. Keys and agents that had them were given the turn defaults.
An agent may carry its own compaction settings, which replace your key’s for the turns that agent
runs.
Keep a working state¶
A thread can hold one small JSON object beside its messages: its working state, the notepad of the task in hand -- the goal, the decisions taken, the current step. Every turn on the thread is shown it right after its instructions and any summary, before the rest of its memory, and it is never summarised away.
Let the model write it. Offer the tool and the model updates the state with a JSON merge
patch, which Core writes when the turn ends. The tool needs tooling beside threads, and only
works on a turn stored on a thread:
curl -s -X POST "$CORE_URL/responses" \
-H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \
-d '{"model": "<a model id>", "thread_id": "thr_0KQ2mR...", "input": "Plan the move.",
"tools": [{"type": "tool", "name": "builtin__update_working_state"}]}'
A turn that fails or is cancelled writes nothing, and a turn's write is dropped when your
application wrote the state while the turn ran: yours stands, and the audit trail records
thread.working_state_dropped. An agent can fix the shape of the state with a JSON Schema,
working_state_schema, on its version; a write that does not fit is refused and names where.
What a call looks like on the thread. The tool takes one argument, patch, the JSON merge
patch itself, and nothing else. A thread's items (GET /threads/{thread_id}/items) show the call
as a function_call whose arguments is that object as JSON text, followed by its result, which
names the version the state is written at and its size in bytes:
{
"items": [
{
"id": "itm_0KQ2n8...",
"type": "function_call",
"call_id": "call_7Hf2...",
"name": "builtin__update_working_state",
"arguments": "{\"patch\": {\"goal\": \"Move the office\", \"step\": \"Book the van\"}}",
"namespace": null,
"status": "completed",
"response_id": "resp_0KQ2n7..."
},
{
"id": "itm_0KQ2n9...",
"type": "function_call_output",
"call_id": "call_7Hf2...",
"name": "builtin__update_working_state",
"output": "{\"version\": 3, \"size\": 118}",
"error": null,
"artifacts": [],
"citations": [],
"response_id": "resp_0KQ2n7..."
}
],
"next_cursor": null
}
Parse arguments and read patch to show what the model changed under that answer. A patch
is a change, not the whole state: it merges into the state before it. A call whose result has
error set and output null was refused, and its patch was not applied. A patch on the thread
also stays there when its turn failed or its write was dropped, so read the state itself with
GET /threads/{thread_id}/state when you need what is stored now.
Read and write it yourself.
| Endpoint | Result |
|---|---|
GET /threads/{thread_id}/state |
The state, its version ("0" before the first write) and where it was carried from |
PUT /threads/{thread_id}/state |
Replace the state with the JSON object you send |
PATCH /threads/{thread_id}/state |
Merge a JSON merge patch into it: null removes a member |
Send the version you read as If-Match to be told 409 with reason_code: "stale_version"
when someone else wrote in between. A state is at most 16 KiB by default; a larger one is refused
with 422 and never shortened. A state written while words of a sealed copy were in the
conversation (Publish and install through the API) reads
as "[sealed]"; you cannot patch it, only replace it whole with PUT.
Carry it into a new conversation. Name an earlier thread as working_state_from when you
start a new one, on POST /threads or on the POST /responses call that creates the thread. The
new thread starts with a copy; the earlier one keeps its own. The request must name the person it
is for -- with X-Core-Subject, a subject token or user (Send a subject on a
turn) -- because a notepad is carried
only between one person's own conversations (Memory):
curl -s -X POST "$CORE_URL/responses" \
-H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \
-H 'X-Core-Subject: {"id": "anna"}' \
-d '{"model": "<a model id>", "input": "Where were we?", "working_state_from": "thr_0KQ2mR..."}'
Only a thread of the same person, which your key may read, can be named; any other answers 404.
Naming one on a call that continues a thread is refused. POST /messages cannot carry a notepad
over yet.