Thread a multi-turn conversation
In this tutorial we will hold a three-turn conversation with a council, where each turn depends on the one before. Along the way we will look at exactly what the council receives, and see the two ways a thread loses its own question.
There are two endpoints, and which one you need is decided by a single question: do you choose the council per request?
POST /v1/chat/completions |
POST /deliberation |
|
|---|---|---|
| council / rounds per request | no — fixed by the policy | yes (agent_names, deliberation_rounds) |
| takes | messages[], server flattens |
user_query — one string you build |
| threading | x-nsed-session-id header |
conversation_id + new_turn |
If you are building a chat that lets a user pick who deliberates, you need
/deliberation, and this tutorial takes that path. A client that is happy with a
fixed policy should prefer the compat endpoint, because the server does the
flattening for it.
You need a running orchestrator and an operator token. We will use curl, so
nothing has to be installed.
1. Ask the first question
Pick a thread id now and keep it for every turn. It is the conversation_id;
each turn still gets its own room_id.
The id is yours to choose — a thread key from your own store. It must not be
the room or job id: those carry a per-turn nonce, and a session keyed on them is
new every turn. Nothing rejects a missing conversation_id; a Claude-backed
agent simply starts a fresh session each time and you lose continuity quietly, so
this is the field to get right first.
THREAD="thread-9f2c"
curl -s https://api.example.xyz/deliberation \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d "{
\"room_id\": \"room-$(openssl rand -hex 4)\",
\"conversation_id\": \"$THREAD\",
\"user_query\": \"What is the sound of one hand clapping?\",
\"agent_names\": [\"Corepunk01\", \"Corepunk02\", \"Corepunk03\"],
\"deliberation_rounds\": 2
}"
The first turn needs no new_turn — the query is the new turn.
2. Ask a follow-up that cannot stand alone
Here is the shape to copy. Send both: the whole thread as user_query, and
this turn alone as new_turn.
curl -s https://api.example.xyz/deliberation \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d "{
\"room_id\": \"room-$(openssl rand -hex 4)\",
\"conversation_id\": \"$THREAD\",
\"user_query\": \"[user] What is the sound of one hand clapping?\n\n[assistant] A Zen koan attributed to Hakuin Ekaku…\n\n[user] yo what about forests?\",
\"new_turn\": \"yo what about forests?\",
\"agent_names\": [\"Corepunk01\", \"Corepunk02\", \"Corepunk03\"],
\"deliberation_rounds\": 2
}"
Two rules in that request, and the next two steps show why each is needed.
user_query carries the whole thread, rendered as [role] content blocks
separated by blank lines — the same shape the compat endpoint produces from a
messages[] array, and the same one
quorum_rs::conversation::flatten_conversation produces for the TUI. Match it
exactly: a lone user message is sent bare, with no prefix, and every other
case is labelled.
new_turn carries only this send's message. Not the thread, not a summary.
Or let the server render it
Rendering that string by hand means keeping the rules in step with the server. If
you would rather not, send messages instead and the server derives both fields:
curl -s https://api.example.xyz/deliberation \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d "{
\"room_id\": \"room-$(openssl rand -hex 4)\",
\"conversation_id\": \"$THREAD\",
\"messages\": [
{\"role\": \"user\", \"content\": \"What is the sound of one hand clapping?\"},
{\"role\": \"assistant\", \"content\": \"A Zen koan attributed to Hakuin Ekaku…\"},
{\"role\": \"user\", \"content\": \"yo what about forests?\"}
],
\"agent_names\": [\"Corepunk01\", \"Corepunk02\", \"Corepunk03\"],
\"deliberation_rounds\": 2
}"
user_query is flattened with the same function used everywhere else, and
new_turn becomes the last user turn. Send messages or user_query — both
together is rejected, because they would disagree and picking one silently would
hide the mistake. A system turn is rejected too: instructions belong in
system_instructions, not in the task.
This is the form to prefer from a language that cannot call the Rust renderer.
Leave new_turn out when you send messages — deriving it is what this form is
for, and stating it only adds a way to be rejected. If you do state it, it must
be the last user turn.
Turns you send this way come back as turns: the job keeps them, so reading it
later returns messages alongside user_query. Use those. Do not try to
split user_query back into turns — see step 3 for why that string cannot be
parsed, only rendered.
3. Look at what the council actually received
Do this once and the rest of the tutorial explains itself. The task the agents deliberated on is in the room's manifest:
nats --creds ~/.nsed/agent.creds -s "$NATS_URL" \
kv get nsed_hist_room-<the id you sent> manifest --raw \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["user_query"])'
[user] What is the sound of one hand clapping?
[assistant] A Zen koan attributed to Hakuin Ekaku…
[user] yo what about forests?
That string is what a stateless agent sees. The deliberation core takes one task string — the agents are N proposers and evaluators, each building its own prompt — and for an agent talking to a plain LLM, every call rebuilds that prompt from scratch. There is no session holding the earlier turns; for that agent, this string is where they exist.
Notice that it is a rendering, and only that. Nothing escapes the [user]
labels, blank turns were dropped on the way in, and a lone user turn is written
bare with no label at all. So the arrow only points one way: ask this string what
turns it was built from and a message that happens to contain a line like
[user] foo answers with a turn nobody sent. That is why the job keeps the turns
as well, and why you read those instead of splitting this:
nats --creds ~/.nsed/agent.creds -s "$NATS_URL" \
kv get nsed_hist_room-<the id you sent> manifest --raw \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["messages"])'
4. Why both fields, and not just new_turn
new_turn reaches an agent through AgentContext::delta_task(), which returns
new_turn when it is set and the full task otherwise. Each agent takes what it
can use:
- A session-capable agent — the Claude/MCP path — resumes the provider-side
session that
conversation_ididentifies. Earlier turns are already in that session, so it wants the delta and nothing more. Re-sending the thread would duplicate what the session already holds. - A stateless agent — anything on a plain LLM, which is most councils —
reads the full task. It has no session. Give it only
new_turnand it receives one line with no context;yo what about forests?becomes unanswerable.
Send both and each agent type is served correctly. That is the whole design: the same request satisfies a council mixing both kinds, which is normal.
If your council is all plain-LLM agents — the common case — then
conversation_id and new_turn do nothing for it today: no agent on that path
consults the delta, so user_query is the whole story. Set them anyway. They cost
one field each, and they begin working the moment a session-capable agent joins
the council, without a client change.
It is worth knowing what that session is. conversation_id is hashed with the
agent's name into a deterministic id — the same thread and agent always produce
the same one — which the provider is then asked to resume, skipping the system
prompts and context it already holds. That transcript lives with the agent, not
in this service. So if an agent restarts, or a different host picks up the next
turn, the resume finds nothing and the agent falls back to reading the full task.
user_query is the only portable copy of your thread, which is the second reason
to keep sending it.
5. See what goes wrong if you write prose instead
Now break it deliberately. Put the thread in user_query as a narrative rather
than as labelled turns:
{
"user_query": "Context — the conversation so far:\n\n1. You asked: What is the sound of one hand clapping?\n Council answered: A Zen koan… (600 more words)\n\n---\n\nFollow-up:\nyo what about forests?"
}
Read it back with step 3's command. It is stored exactly as written, and two things follow — both observed on a real thread:
- The question is buried. That payload was 4 698 characters and the live question 140 of them — 3 % — sitting last, after the longest text in the request.
- Prior answers outrank it. Each pasted answer dwarfs every question, so the council's own earlier output becomes the bulk of its next instruction. The agents closed an answer about medicine, law and warfare with the koan from three turns earlier, and one asked the user whether to prioritise a Zen metaphor while evaluating it.
Neither is misbehaviour. Each agent weighed the text it was told to solve, and it
was told the whole narrative was the task. Labelled turns and a new_turn give
it somewhere to look instead.
6. Keep the thread going
Every later turn repeats step 2: append the newest exchange (or the new
messages entry), keep the same conversation_id, and use a fresh room_id.
If your chat can branch — an edit-and-resend from mid-thread, or a fork under
an earlier answer — give the fork its own conversation_id. Reusing the parent's
resumes a session whose transcript still contains the branch you abandoned, so
the agent argues with a message your user retracted. The interactive client keys
on a per-branch id for exactly this: a fork under a non-leaf node takes a fresh
one, and a reply at the tip keeps its parent's.
What you did
You threaded a conversation while still choosing the council per turn. Two rules:
Send
messagesand let the server render. Keepconversation_idstable across the thread — your own key, never the room id — and give a branch its own.
Next
- Policy & sessions — why the client owns the transcript, and why policy is the model.
- Chat Completions reference — the other endpoint, for clients that accept a fixed policy and want the server to flatten for them.