Noolog Docs

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_id identifies. 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_turn and 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 messages and let the server render. Keep conversation_id stable 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.