Skip to main content
A research session rarely ends after the first answer. Users follow up, refine their question, or branch into a new line of inquiry. Both the Research Agent and Workflows support multi-turn continuation, but the surfaces differ slightly and involve four related identifiers that are easy to confuse. This guide disambiguates them, shows how persistence interacts with continuity, and provides worked examples for the common multi-turn and branching patterns.

The four identifiers

IDEndpointDirectionWhat it identifies
chat_idResearch Agentrequest and responseA persistent conversation thread. Auto-created if absent on the first request; pass the same value on subsequent requests to continue the same thread.
execution_idWorkflowsrequest and responseA specific workflow execution. Pass a previous execution_id to resume the conversation produced by that execution.
from_checkpoint_idResearch Agentrequest onlyThe point in an existing conversation to resume from. Lets you rewrite or branch a conversation by going back to an earlier state.
checkpoint_idResearch Agentresponse onlyA cursor in the COMPLETE event marking the conversation state after this turn. Store it and pass as from_checkpoint_id on the next request to continue from this exact point.
In practice:
  • A vanilla follow-up turn uses chat_id (or execution_id) alone to identify the conversation.
  • A branching turn additionally uses from_checkpoint_id to specify where in the conversation to resume.
  • The checkpoint_id you store today is what becomes a from_checkpoint_id tomorrow.

persistence_mode

The Research Agent request accepts persistence_mode with two values. Both allow multi-turn follow-ups; they differ in how long the conversation survives.
  • disabled (default) — creates an ephemeral conversation thread. Follow-up turns against the same chat_id work for approximately one hour. After that, the underlying checkpoint is evicted and a resume attempt returns HTTP 404 with the detail "Cannot follow up: chat expired".
  • enabled — creates a persistent conversation thread. The chat_id and checkpoint_id returned in COMPLETE can be used to continue or branch the conversation indefinitely.
The mode is immutable for the life of a conversation. It is fixed when the conversation is created on turn 1; every resume request must pass the same value. A mismatch returns HTTP 400 on the Research Agent (with detail "persistence_mode mismatch") and HTTP 409 on Workflows. Choosing a mode: leave the default disabled for one-off questions and short sessions that wrap up within an hour. Set enabled on turn 1 when the conversation needs to survive longer — branching workflows, research threads spanning a working session, anything you want a user to resume the next day. Workflows expose persistence_mode on the synchronous execute endpoint with the same semantics (default disabled, immutable, mismatch returns 409). Asynchronous workflows always persist because the events blob is the client’s only way to read execution state after the SSE connection closes; they do not expose persistence_mode.

The "INITIAL" sentinel

Passing from_checkpoint_id: "INITIAL" is a special instruction that truncates the entire conversation and starts over with the new message. Use it when the user explicitly says “let’s start over” or when you detect a context-poisoning mistake (a typo in a name, a wrong company id, a stale assumption) that you want to clear cleanly while keeping the same chat_id.
payload = {
    "chat_id": current_chat_id,
    "from_checkpoint_id": "INITIAL",
    "message": "Let's start fresh: analyze NVIDIA's data center segment.",
    "persistence_mode": "enabled",
    "research_effort": "standard",
}
"INITIAL" deletes all prior interactions for that chat_id. There is no undo.

Worked example: multi-turn conversation

The pattern below persists chat_id and checkpoint_id between turns, letting the user ask follow-up questions on top of the agent’s previous answers.
import json
import requests


def turn(api_key: str, message: str, chat_id: str | None, checkpoint_id: str | None):
    endpoint = "https://agents.bigdata.com/v1/research-agent"
    headers = {"X-API-KEY": api_key, "Content-Type": "application/json"}
    payload: dict = {
        "message": message,
        "research_effort": "standard",
        "persistence_mode": "enabled",
    }
    if chat_id:
        payload["chat_id"] = chat_id
    if checkpoint_id:
        payload["from_checkpoint_id"] = checkpoint_id

    answer = ""
    new_chat_id: str | None = None
    new_checkpoint_id: str | None = None

    with requests.post(endpoint, headers=headers, json=payload, stream=True, timeout=300) as r:
        r.raise_for_status()
        for raw_line in r.iter_lines(decode_unicode=True):
            if not raw_line or not raw_line.startswith("data: "):
                continue
            try:
                event = json.loads(raw_line[6:])
            except json.JSONDecodeError:
                continue

            new_chat_id = event.get("chat_id") or new_chat_id
            msg = event.get("message", {})
            if msg.get("type") == "ANSWER":
                answer += msg.get("content", "")
            elif msg.get("type") == "COMPLETE":
                new_checkpoint_id = msg.get("checkpoint_id")
            elif msg.get("type") == "ERROR":
                raise RuntimeError(msg.get("error"))

    return answer, new_chat_id, new_checkpoint_id


# First turn: no chat_id yet, no checkpoint.
api_key = "..."
answer1, chat_id, ckpt = turn(api_key, "Summarize NVIDIA's Q2 FY26 results.", None, None)

# Follow-up turn: reuse chat_id; pass the checkpoint from the previous turn.
answer2, chat_id, ckpt = turn(api_key, "How does that compare to AMD?", chat_id, ckpt)

# Another follow-up: same pattern.
answer3, chat_id, ckpt = turn(api_key, "Which one has better margins?", chat_id, ckpt)
The chat_id returned by each event identifies the conversation thread; the checkpoint_id returned in COMPLETE is your cursor for the next turn. Persist both server-side or in client storage if you need conversations to survive across sessions.

Branching a conversation

To “rewind” a conversation — for example, when the user edits an earlier message or wants to explore a different follow-up question from a known good state — pass the checkpoint_id from the turn you want to resume from, with the same chat_id. Any interactions after that checkpoint are discarded, and the new message takes their place.
# Suppose we have a stored checkpoint after the first turn (`ckpt_after_turn_1`)
# and we want to branch off in a different direction.
branch_answer, chat_id, branch_ckpt = turn(
    api_key,
    "Actually, focus on the gaming segment instead.",
    chat_id,
    ckpt_after_turn_1,
)
After this call, the conversation’s history consists of: the system prompt, the first user message, the first answer, and the new branched turn. The follow-ups that originally came after ckpt_after_turn_1 no longer exist on the server.

Workflows: resuming by execution_id

Workflows do not expose checkpoints. Instead, each execution has an execution_id that the streaming response carries on every event. Pass that id back in a later request to continue the same conversation.
import json
import requests


def workflow_turn(api_key: str, template_id: str, message: str, execution_id: str | None):
    endpoint = "https://agents.bigdata.com/v1/workflow/execute"
    headers = {"X-API-KEY": api_key, "Content-Type": "application/json"}
    payload: dict = {"template": template_id, "input": {"message": message}}
    if execution_id:
        payload["execution_id"] = execution_id

    answer = ""
    new_execution_id: str | None = None

    with requests.post(endpoint, headers=headers, json=payload, stream=True, timeout=600) as r:
        r.raise_for_status()
        for raw_line in r.iter_lines(decode_unicode=True):
            if not raw_line or not raw_line.startswith("data: "):
                continue
            try:
                event = json.loads(raw_line[6:])
            except json.JSONDecodeError:
                continue

            new_execution_id = event.get("execution_id") or new_execution_id
            delta = event.get("delta", {})
            if delta.get("type") == "ANSWER":
                answer += delta.get("content", "")
            elif delta.get("type") == "ERROR":
                raise RuntimeError(delta.get("error"))

    return answer, new_execution_id
Resuming a workflow by execution_id continues the conversation but does not re-run the original template’s research plan from scratch; the agent treats the new input (or follow-up message) as a continuation, with prior interactions in context.

Common pitfalls

  • Treating the default disabled mode as suitable for long sessions. Ephemeral threads expire after about an hour; a follow-up that arrives later returns HTTP 404 with detail "Cannot follow up: chat expired". Set persistence_mode: "enabled" on turn 1 if the conversation needs to survive longer than that.
  • Switching modes mid-conversation. The mode is immutable once a conversation is created. Resume requests must pass the same value used on turn 1 or the server returns HTTP 400 (Research Agent) / 409 (Workflows).
  • Storing chat_id but not checkpoint_id. A chat_id alone is enough for the simple “continue the latest turn” case, but if you want stable replay or branching, you also need the checkpoint_id returned in COMPLETE.
  • Mixing chat_id between users. The chat_id is a server-side conversation identifier; do not share it across users or sessions you want isolated.
  • Treating checkpoint_id as introspectable. It is an opaque string; do not parse, compare, or derive meaning from its contents.
  • Calling from_checkpoint_id: "INITIAL" accidentally. It deletes the entire thread. Guard this code path behind an explicit user action.

Next steps

Streaming responses

Reference for every message type, including how checkpoint_id arrives in COMPLETE.

Error handling

Handle 404s from invalid checkpoint IDs and other continuity failures.