Skip to content

Multi-Turn Prompts for Gathering Information

12 min read · updated August 4, 2026

A collection conversation has one hard requirement — know when it is finished — and one hard failure — feeling like a form read aloud. Both are solved in the same place: a slot state your code owns, sent into every turn, with completion defined as a condition over it.

The prompt

You are gathering the information in <slots> from the person you are talking
to. This is a conversation, not a form.

<slots>
name           required  free text
company_size   required  one of: 1-9, 10-49, 50-249, 250+
current_tool   required  free text; "none" is a valid answer
timeline       required  one of: now, this_quarter, this_year, exploring
budget_band    optional  one of: under_1k, 1k_5k, 5k_20k, over_20k
email          required  a valid email address
</slots>

<filled> is what has already been established. Trust it.

Rules for the conversation:
- Ask at most two things per turn, and only when they naturally belong
  together.
- Never re-ask anything in <filled>. If someone answered in passing, however
  loosely, fill it and confirm it inside your next sentence rather than
  asking again: "Sounds like around 30 people - I'll put you down as 10 to
  49." Then move on.
- If they ask you a question, answer it first. Their question outranks your
  next slot. Return to the slot afterwards, in the same turn if it fits.
- If they decline a slot, mark it declined and never raise it again. Do not
  rephrase and retry.
- If an answer does not fit an enumerated slot, offer the nearest options in
  your own words. Never read the list out.
- Do not say "I just need a few more details" more than once.
- Do not thank them for every answer.

Return, every turn:
{"say": "<what to send them>",
 "slots": {"<slot>": {"value": <value or null>,
                      "status": "filled" | "inferred" | "declined" | "empty",
                      "source": "<verbatim span from their message, or null>"}},
 "complete": true | false,
 "blocked_on": "<what is stopping completion, or null>"}

Status rules:
- "filled"   they stated it. "source" is the span where they did.
- "inferred" you derived it from something they said. "source" is the span
             you derived it from, and it is REQUIRED. An inferred slot with
             no source is not allowed; use "empty".
- "declined" they refused or deflected twice. Terminal; never ask again.
- "empty"    not yet established.

"complete" is true when every required slot is "filled" or "declined", and
no slot is "inferred" without a source. It is not true because the
conversation feels finished.

<filled>{{slot_state_json}}</filled>
<history>{{last_n_turns}}</history>
Their latest message: {{message}}

Slot state lives in your code

This is the architectural point and it is the one that decides whether the thing works at turn 20.

If the only record of what has been collected is the conversation transcript, then every turn the model must re-derive the state by re-reading the whole conversation. That re-derivation is a task with an error rate, it is performed again on every turn, and its errors are sticky: a slot mistakenly re-derived as empty gets asked again, the person answers with irritation, and now the transcript contains an exchange that makes the next derivation harder.

Instead: your code keeps the slot dictionary. Each turn you send it in filled, the model returns an updated one, and you merge with your own rules — never regressing a filled slot to empty, never overwriting a declined.

TERMINAL = {"filled", "declined"}

def merge(current: dict, proposed: dict) -> dict:
    """The model proposes. Your code decides. Never lose a settled slot."""
    out = dict(current)
    for name, new in proposed.items():
        old = out.get(name, {"status": "empty", "value": None, "source": None})
        if old["status"] == "declined":
            continue                                  # terminal, always
        if old["status"] == "filled" and new["status"] != "filled":
            continue                                  # never regress
        if new["status"] == "inferred" and not new.get("source"):
            continue                                  # unsourced inference is not state
        out[name] = new
    return out

def complete(state: dict, schema: dict) -> bool:
    return all(state.get(k, {}).get("status") in TERMINAL
               for k, spec in schema.items() if spec["required"])

With this in place you can also truncate the history aggressively — the last six turns is usually plenty — because the state is not in it. That keeps the prompt short and the cost flat as the conversation grows, instead of both climbing with every turn.

Filled, inferred, declined

StatusDescription
filledThey stated it. The source span is where. Safe to use without confirmation.
inferredYou derived it. “We’re about thirty people” gives company_size = 10-49 — a correct inference and still an inference. Requiring the source span makes it reviewable, and it is the status your code should confirm out loud rather than silently accept.
declinedTerminal, and terminal means terminal. The most common way these conversations become unpleasant is a slot that is asked again in a new phrasing after a refusal. One line in the prompt, one branch in the merge, and it never happens.
emptyNot yet established. The only status the model may move away from freely.

Collapsing inferred into filled is the tempting simplification and it costs you the ability to confirm selectively. You cannot confirm everything — that is the interrogation failure — and you should not confirm nothing. Confirming inferences only is the right amount, and it needs the distinction to exist.

Not an interrogation

Four rules carry this, and the second is the one that matters most.

  • At most two slots per turn. Three or more reads as a form. Two that belong together — name and company, timeline and budget — reads as a conversation.
  • Their question outranks your slot. The single difference between a conversation and an interrogation is whether questions can go the other way. A model that acknowledges a question and continues collecting is worse than a form, because it is a form pretending.
  • Fill from passing mentions and confirm inline. Asking for something the person has already told you is the failure they notice and remember. The example phrasing in the prompt — state the inference, then move on — does this in one clause and gives them a chance to correct it without a question mark.
  • Never read the enumeration out. “Are you 1 to 9, 10 to 49, 50 to 249, or 250 plus?” is a database schema spoken aloud. “Roughly how many people are you?” gets the same information and lets the model do the bucketing.

Add a stall escape in your loop rather than the prompt: after three turns with no new slot reaching a terminal status, stop and hand over with what you have. Without it, a conversation with someone who will not give an email continues indefinitely, and every turn costs money and goodwill.

The completion criterion

“Complete when the conversation feels finished” is not a criterion, and a model asked to judge it will end early on a cooperative person and late on a chatty one. The condition here is computable, and your code computes it — the model’s complete field is a hint you check rather than a decision you accept.

Two clauses, both load-bearing. Every required slot must be terminal: declined counts, because a person who will not give a budget has finished that slot as definitively as one who gave it. And no slot may be inferred without a source, which prevents completion by assumption — the failure where the conversation ends with a full-looking record whose fields nobody said.

Optional slots are not in the condition at all. If an optional slot blocks completion, it was not optional; delete the distinction or fix the schema.

Four turns, with the state

The state after each turn is what makes the behaviour legible. Watch three slots fill without being asked for, one question go the other way, and one slot decline.

turn 1
  them: "Hi - we're a 30-person agency in Rotterdam, currently on Trello,
         and honestly it's falling apart. I'm Sanne."
  state after:
    name          filled   "Sanne"        <- "I'm Sanne."
    company_size  inferred "10-49"        <- "we're a 30-person agency"
    current_tool  filled   "Trello"       <- "currently on Trello"
    timeline      empty
    email         empty
  say: "Thanks Sanne - I'll put you down as 10 to 49 people, and Trello as
        what you're on now. Roughly when are you hoping to have moved?"

  Three slots from one message, none of them asked for. company_size is
  inferred, so it is confirmed out loud in the same sentence; the other two
  are filled and are not.

turn 2
  them: "This quarter ideally. Does your import handle Trello attachments?
         That's the thing that's stopped us before."
  state after:  timeline filled "this_quarter"  <- "This quarter ideally"
  say: "It does - attachments come across with the cards, including ones over
        the 10 MB Trello limit if they were added by URL. Since you're moving
        this quarter, what's the best email to send the migration checklist
        to?"

  Their question is answered first and concretely. The slot follows in the
  same turn, attached to a reason they would want to give it.

turn 3
  them: "[email protected]. I'd rather not get into budget at this stage."
  state after:
    email         filled   "[email protected]"
    budget_band   declined
  say: "Sent. No problem on budget - I've left it out."

  budget_band is terminal. It is optional anyway, so it was never going to
  block completion, but the declined status is what stops a later turn
  circling back to it.

turn 4
  complete: true
    every required slot is filled; company_size is inferred WITH a source.
    Four turns, one of which was them asking a question.

The turn-2 exchange is the one to copy. The slot request is attached to something the person just said they wanted — the migration checklist — rather than presented as the next item on a list. That is the whole difference between a conversation and a form, and it costs nothing except writing the rule that their question comes first.

Note also what did not happen at turn 1: the assistant did not ask “and how many people are you?” after being told thirty. That re-ask is the failure everybody has experienced, it follows directly from state that is re-derived rather than kept, and it is the reason the merge function exists.

When it stops working

  • Re-asks appear. The failure users complain about. Detect it by comparing the slots mentioned in say against terminal slots in filled. Almost always a state-passing bug rather than a prompt one — check what your code actually sent.
  • inferred rises relative to filled. The model has started deriving rather than asking. Sample the sources; if they do not support the inference, tighten the source requirement.
  • Turns per completion rises. Track the median. A rise with the same slot count usually means the two-slots-per-turn rule has become one, and the conversation now takes twice as long for no gain.
  • declined never appears. People decline. If the status is never set, the model is treating deflection as an invitation to rephrase, which is the behaviour that makes people leave.