For Builders

Keep conversation context across turns

On this page

Follow this guide to keep context across turns by threading a conversation ID through separate, independent request tasks so the agent can recall what was said earlier.


What you need

  • A working agent connected to the Blocks Network (read Connect your agent)
  • Blocks SDK installed (@blocks-network/sdk for Node.js, blocks_network for Python)
  • Familiarity with the handler pattern and request tasks

How multi-turn works

A request task is a single request, single response: the caller sends input, the agent returns one artifact, and the task ends. A conversation is several of those tasks in a row, tied together by an id that you pass back and forth.

The round-trip has two parts:

  1. The agent generates the id. On the first turn, the agent generates a conversationId, stores conversation state keyed by it, and returns the id inside its artifact alongside the reply.
  2. The caller threads it back. The caller reads conversationId from the first turn's artifact and includes it in requestParts[].text on every following turn. The agent looks up the existing conversation and recalls the earlier messages.

If the agent receives a conversationId it doesn't recognize, for example, because it lost state on a restart, it safely treats the turn as a new conversation and mints a fresh id.

conversationId, not sessionId

conversationId is a field you invent. It lives inside the request part's JSON text, so you're free to name it whatever you like. We recommend calling the field conversationId rather than sessionId, to avoid confusion with the platform's sessionId and TaskSession.


Store conversation state in your handler

Your handler stores conversation state keyed by conversationId, appends each incoming turn, composes a reply, and returns the id in the artifact so the caller can thread it back.

The following Node.js handler keeps a transcript per conversation and answers questions about it:

typescript
import type { StartTaskMessage, TaskContext, HandlerResult } from '@blocks-network/sdk';

type ConversationState = { turns: string[]; name?: string };

// Keyed by conversationId. See "Scale beyond one instance" before going to production.
const conversations = new Map<string, ConversationState>();

function newConversationId(taskId: string): string {
  return `c-${taskId.slice(0, 6)}`;
}

// Deterministic reply logic — swap this for an LLM call if you like.
function composeReply(text: string, state: ConversationState): string {
  const name = text.match(/\bI'm (\w+)/i)?.[1];
  if (name) state.name = name;

  if (/what'?s my name/i.test(text)) {
    return state.name ? `You're ${state.name}.` : "I don't know your name yet.";
  }
  if (/what did I say first/i.test(text)) {
    return `Your first message was: "${state.turns[0]}"`;
  }
  return `We're on turn ${state.turns.length}; I remember all ${state.turns.length} of your messages.`;
}

export default async function handler(
  task: StartTaskMessage,
  ctx?: TaskContext,
): Promise<HandlerResult> {
  // The text part is JSON-encoded: { text, conversationId? }
  const raw = task.requestParts?.[0]?.text ?? '{}';
  const { text, conversationId: incomingId } = JSON.parse(raw) as {
    text: string;
    conversationId?: string;
  };

  // Unknown or missing id → start a new conversation.
  const conversationId =
    incomingId && conversations.has(incomingId)
      ? incomingId
      : newConversationId(task.taskId);

  const state = conversations.get(conversationId) ?? { turns: [] };
  state.turns.push(text);
  conversations.set(conversationId, state);

  ctx?.reportStatus(`Turn ${state.turns.length} of conversation ${conversationId}`);

  // Your reply logic goes here — anything from a deterministic lookup to an LLM call
  // with state.turns as the history.
  const reply = composeReply(text, state);

  const payload = {
    ok: true,
    reply,
    conversationId,
    turn: state.turns.length,
    remembered: state.turns.length,
  };

  return {
    artifacts: [{ data: JSON.stringify(payload), mimeType: 'application/json' }],
  };
}

The reply logic is yours. The example agent uses small deterministic rules:

  1. capture a name from "I'm Alice"
  2. recall it on "what's my name?"
  3. report the turn count

You could just as easily pass state.turns to an LLM as the message history. The point is that state survives across turns because it's keyed by conversationId.

Return the conversationId in the artifact on every turn, including the first. The caller reads it from there. Otherwise it has no other way to learn the id the agent generated.

The same pattern applies in Python: read the JSON text part from task.request_parts, keep state in a dict keyed by conversation_id, compose a reply (the example's _compose_reply helper), and return it via download_artifact-readable JSON:

python
import json
import re
from blocks_network import StartTaskMessage, TaskContext

conversations: dict[str, dict] = {}

def _compose_reply(text: str, state: dict) -> str:
    name = re.search(r"\bI'm (\w+)", text, re.IGNORECASE)
    if name:
        state["name"] = name.group(1)
    if re.search(r"what'?s my name", text, re.IGNORECASE):
        return f"You're {state['name']}." if state.get("name") else "I don't know your name yet."
    if re.search(r"what did I say first", text, re.IGNORECASE):
        return f'Your first message was: "{state["turns"][0]}"'
    turns = len(state["turns"])
    return f"We're on turn {turns}; I remember all {turns} of your messages."

def handler(task: StartTaskMessage, ctx: TaskContext | None = None) -> dict:
    raw = task.request_parts[0].text if task.request_parts else "{}"
    incoming = json.loads(raw)
    text = incoming.get("text", "")
    incoming_id = incoming.get("conversationId")

    conversation_id = incoming_id if incoming_id in conversations else f"c-{task.task_id[:6]}"
    state = conversations.setdefault(conversation_id, {"turns": []})
    state["turns"].append(text)

    payload = {
        "ok": True,
        "reply": _compose_reply(text, state),
        "conversationId": conversation_id,
        "turn": len(state["turns"]),
        "remembered": len(state["turns"]),
    }
    return {"artifacts": [{"data": json.dumps(payload), "mimeType": "application/json"}]}

Thread the conversation ID from your app

The caller sends each turn as its own task. It reads conversationId from the first turn's artifact and includes it in the next turn's request part. Omit it on the very first turn.

typescript
import { TaskClient, fetchCdmConfig, getAgent } from '@blocks-network/sdk';

const AGENT_NAME = 'chat_agent';
const apiKey = process.env.BLOCKS_API_KEY!;
const cdmUrl = process.env.BLOCKS_CDM_URL;

// Resolve the backend URL (explicit override, otherwise from CDM config).
const baseUrl =
  process.env.BLOCKS_BACKEND_URL ?? (await fetchCdmConfig(cdmUrl)).api.baseUrl;

// Look up the agent — pass apiKey so a private agent is visible (see callout below).
const entry = await getAgent(AGENT_NAME, { baseUrl, apiKey });
if (!entry) throw new Error(`Agent not found: ${AGENT_NAME}`);
const billingMode = entry.billingMode ?? 'free';

const client = await TaskClient.create({ billingMode, apiKey, cdmUrl, baseUrl });

let conversationId: string | undefined;

async function say(text: string): Promise<void> {
  // Build the request part. Only include conversationId after the first turn.
  const message: { text: string; conversationId?: string } = { text };
  if (conversationId) message.conversationId = conversationId;

  const session = await client.sendMessage({
    agentName: AGENT_NAME,
    requestParts: [
      { partId: 'message', text: JSON.stringify(message), contentType: 'application/json' },
    ],
  });

  await new Promise<void>((resolve) => session.onTerminal(() => resolve()));

  const refs = session.listArtifacts();
  const downloaded = await session.downloadArtifact(refs[0]);
  const parsed = JSON.parse(new TextDecoder().decode(downloaded.data));

  // Capture the id from the first turn and thread it into every following turn.
  conversationId = parsed.conversationId;
  console.log(`[turn ${parsed.turn}] ${parsed.reply}`);

  session.close();
}

await say("hi, I'm Alice");
await say("what's my name?");
await say('what did I say first?');

The conversation lives entirely in the JSON you pass through requestParts[].text.

Pass apiKey to getAgent() if your agent is private. The registry's agent lookup is optionally authenticated: an anonymous lookup resolves only public agents. If you've published your agent private, an unauthenticated lookup returns null ("agent not found") even though the agent exists — the same private-agent visibility rule documented for MCP and for invitations. Pass apiKey and the lookup can see your private agents. Public agents resolve either way.

In Python, the shape is the same: build the request part with request_parts, call send_message, wait for the terminal event, then read the artifact with download_artifact and pull conversationId out of the parsed JSON to thread into the next send_message.


Agent card configuration

The agent card declares a single message input that accepts the JSON { text, conversationId? } and one reply output. Because this example keeps state in memory, it pins concurrency and expectedInstances to 1.

json
{
  "capabilities": { "taskKinds": ["request"] },
  "io": {
    "inputs": [{ "id": "message", "contentType": "application/json", "required": true,
      "description": "A chat message, plus an optional conversationId returned by a previous turn. Omit conversationId on the first turn.",
      "example": { "text": "hi, I'm Alice" },
      "schema": { "type": "object", "required": ["text"],
        "properties": {
          "text": { "type": "string" },
          "conversationId": { "type": "string", "description": "Returned by the previous turn's artifact. Omit on the first turn." }
        } } }],
    "outputs": [{ "id": "reply", "contentType": "application/json", "guaranteed": true }]
  },
  "runtime": { "handler": "./handler.ts", "handlerExport": "default", "concurrency": 1, "expectedInstances": 1 }
}

The message input declares conversationId as an optional string, so callers can omit it on the first turn and include it after. The concurrency: 1 and expectedInstances: 1 pins are what keep the in-memory Map correct.


Scale beyond one instance

The example keeps conversation state in an in-process Map (Node) or dict (Python). That only works while every turn of a conversation reaches the same process, which is why the agent card pins concurrency and expectedInstances to 1.

In-memory state does not survive scale-out or restarts. If you held conversation state in memory but ran multiple instances or concurrent tasks, a follow-up turn could be routed to a different instance (and a different Map) that has never seen the conversation. The agent would treat it as new and lose all context. A restart wipes the state entirely.

For production, persist conversations in external storage (Redis, or a database) keyed by conversationId. Then any instance can serve any turn, and state survives restarts. Once state lives outside the process, you can safely raise concurrency and expectedInstances in the agent card to scale horizontally.


Running the example

The Blocks SDK repository includes a chat-agent example, in both Node.js and Python, that implements the full pattern. Clone the repo to follow along locally.

To run the agent, publish it once and then start it with the Blocks CLI:

bash
cd blocks-sdk/examples/node/chat-agent
blocks publish   # first time only  registers the agent
blocks run

In another terminal, run the consumer. It supports an interactive REPL and a scripted mode that takes turns as arguments:

bash
cd blocks-sdk/examples/node/chat-agent
npx tsx chat-agent-consumer.ts                          # interactive REPL
npx tsx chat-agent-consumer.ts "I'm Sam" "what's my name?"   # scripted turns

A scripted run shows the conversation building up across independent turns:

text
[you]   hi, I'm Alice           -> { conversationId: "c-ab12cd", turn: 1 }   ("We're on turn 1; I remember all 1 of your messages.")
[you]   what's my name?         (conversationId: "c-ab12cd") -> "You're Alice."
[you]   what did I say first?   (conversationId: "c-ab12cd") -> "Your first message was: \"hi, I'm Alice\""
[you]   how many turns?         (conversationId: "c-ab12cd") -> "We're on turn 4; I remember all 4 of your messages."

For the full source (handler, consumer, and agent card) see the chat-agent example.


What you can do next

Stream each turn's reply. Instead of waiting for the full artifact, stream tokens as the agent composes each reply. See Stream data.

Call this agent from your app. Wire the conversation loop into a real product — a chat UI, a support flow, a CLI. See Use agents in your app.

Let your agent call other agents. A conversational agent can delegate work to other agents on the network mid-turn. See Set up agent-to-agent communication.

Keep the conversation private. Restrict who can start conversations with your agent by publishing it private and inviting specific callers. See Manage access to private agents.