Hold a conversation
This guide continues a conversation with an agent turn by turn, limits the history its model sees, reads the history, and closes the conversation.
Every turn belongs to a conversation. A run without a conversationId opens a
new one, and its result carries the id. Pass the id with the next message, and
the agent's model sees what was said before.
Before you start
Section titled “Before you start”- An agent with a tool, on a runtime such as
kindgi dev, with Kindgi 0.1's CLI. This guide usesacme.order-deskand itsacme.lookup-ordertool, from Write an agent. - A registered model for answers worth reading. Without one,
dev-echoanswers. - Node 22.18 or later, which runs a TypeScript file directly, Python
3.11 or later, or Java 17 or later, with Kindgi 0.1's client.
The scripts read the API's URL and token from
KINDGI_API_URLandKINDGI_API_TOKEN. Withkindgi dev, they're the URL and token it prints, also in the pack's.kindgirc.json.
Continue a conversation
Section titled “Continue a conversation”Ask your coding agent: "I want my app to send a follow-up message in the same
conversation, then print the conversation's history." For reference, the
code looks like this. The second turn passes the first turn's
conversationId, so the model sees the first:
import { createClient } from '@kindgi/sdk/client';import type { ThreadId } from '@kindgi/sdk/types';
const kindgi = createClient(); // KINDGI_API_URL, KINDGI_API_TOKEN, or the running kindgi dev
type Turn = { conversationId: ThreadId; turnNumber: number; response: { content: string } };
const first = await kindgi.runs.start({ agent: 'acme.order-desk', input: { userMessage: 'Hi, I am Ada. My order is A-1002.' },});const { conversationId } = first.output as Turn;
const second = await kindgi.runs.start({ agent: 'acme.order-desk', input: { userMessage: 'Has it shipped yet?', conversationId },});const turn = second.output as Turn;console.log(turn.turnNumber, turn.response.content);
const history = await kindgi.conversations.messages(conversationId);for (const m of history.data) console.log(m.sequence, m.role, JSON.stringify(m.content));node scripts/chat.ts2 No, your order A-1002 hasn't shipped yet—it's still in processing status. You'll receive an update once it's on its way.0 user "Hi, I am Ada. My order is A-1002."1 agent {"text":"","toolCalls":[{"id":"toolu_01LLRAWoi55mYXt2ztfW1Ap8","name":"acme.lookup-order","arguments":{"orderId":"A-1002"}}]}2 tool {"eta":null,"status":"processing","orderId":"A-1002"}3 agent "Hi Ada! Your order A-1002 is currently being processed. We don't have an estimated delivery date yet, but we'll update you once it ships."4 user "Has it shipped yet?"5 agent "No, your order A-1002 hasn't shipped yet—it's still in processing status. You'll receive an update once it's on its way."from kindgi.client import Kindgi
kindgi = Kindgi() # KINDGI_API_URL, KINDGI_API_TOKEN, or the running kindgi dev
def output(run): if run.output is None: # a run that didn't complete has no output raise SystemExit(f"Run {run.id} ended {run.status}: {run.failure_message}") return run.output
first = kindgi.runs.start( agent="acme.order-desk", input={"userMessage": "Hi, I am Ada. My order is A-1002."},)conversation_id = output(first)["conversationId"]
second = output( kindgi.runs.start( agent="acme.order-desk", input={"userMessage": "Has it shipped yet?", "conversationId": conversation_id}, ))print(second["turnNumber"], second["response"]["content"])
history = kindgi.conversations.messages(conversation_id)for m in history.data: print(m.sequence, m.role, m.content)uv run python scripts/chat.py2 No, your order A-1002 hasn't shipped yet—it's still being processed. You'll be notified once it ships with a delivery date.0 user Hi, I am Ada. My order is A-1002.1 agent {'text': '', 'toolCalls': [{'id': 'toolu_01XKoyp8kWq9o8vVfoJdwmx3', 'name': 'acme.lookup-order', 'arguments': {'orderId': 'A-1002'}}]}2 tool {'eta': None, 'status': 'processing', 'orderId': 'A-1002'}3 agent Hi Ada! Your order A-1002 is currently being processed. Unfortunately, we don't have an expected delivery date available yet—we'll update you once it ships.4 user Has it shipped yet?5 agent No, your order A-1002 hasn't shipped yet—it's still being processed. You'll be notified once it ships with a delivery date.The second message names no order, and the agent answers about A-1002: its
model saw the first turn, including the tool call and its result. The history
is every message in order: the user's, the agent's, each a tool call or an
answer, and each tool result. From the command line, pass the same field:
--input='{"userMessage":"Has it shipped yet?","conversationId":"<id>"}',
where <id> is the conversation's id.
Limit what the model sees
Section titled “Limit what the model sees”A long conversation makes every turn's prompt longer. historyLimit caps how
many earlier messages a turn loads. Ask your coding agent: "Send my agent's
model only the last 20 messages of a conversation." Kindgi's skills in your
pack tell it how (How it knows Kindgi). For
reference, it adds historyLimit to the agent's definition:
// in agents/order-desk/index.ts conversationPolicy: { historyLimit: 20 },# in agents/order_desk.py conversation_policy={"historyLimit": 20},// in src/main/java/acme/agents/OrderDesk.java .set("conversationPolicy", Map.of("historyLimit", 20))// in src/main/scala/acme/agents/OrderDesk.scala .set("conversationPolicy", Map("historyLimit" -> 20))The turn loads the last 20 messages before the new one. Older ones stay in
the conversation but aren't sent. Without historyLimit, every turn loads the
whole history. To see what a turn sent, read its journal with
kindgi runs journal <run-id>: the build-initial-messages step's output has
the messages.
Open, list and close conversations
Section titled “Open, list and close conversations”A conversation can also be opened before its first turn, with a title and the person it's with. Closing one ends it: a turn on a closed conversation is refused. Ask your coding agent: "I want my app to open a conversation with a customer, and close it when we're done." For reference, the code looks like this:
import { KindgiApiError, createClient } from '@kindgi/sdk/client';import type { AgentId } from '@kindgi/sdk/types';
const kindgi = createClient(); // KINDGI_API_URL, KINDGI_API_TOKEN, or the running kindgi dev
const conversation = await kindgi.conversations.open({ agentId: 'acme.order-desk' as AgentId, agentVersion: '0.1.0', title: 'Order A-1002', participantId: 'customer-4411',});console.log(conversation.id, conversation.status, conversation.turnCount);
await kindgi.runs.start({ agent: 'acme.order-desk', input: { userMessage: 'Where is order A-1002?', conversationId: conversation.id },});
const closed = await kindgi.conversations.close(conversation.id);console.log(closed.status, closed.turnCount);
try { await kindgi.runs.start({ agent: 'acme.order-desk', input: { userMessage: 'One more thing…', conversationId: conversation.id }, });} catch (err) { if (!(err instanceof KindgiApiError) || err.error.code !== 'conflict') throw err; console.log(err.error.reason, err.message);}
const list = await kindgi.conversations.list({ agent: 'acme.order-desk' as AgentId, status: 'closed' });console.log(list.data.map((c) => c.id));88356874-400d-4bcd-9708-63ab51f88dd3 open 0closed 1conversation-closed Conversation "88356874-400d-4bcd-9708-63ab51f88dd3" is closed[ '88356874-400d-4bcd-9708-63ab51f88dd3', …]from kindgi.client import ConflictError, Kindgi
kindgi = Kindgi() # KINDGI_API_URL, KINDGI_API_TOKEN, or the running kindgi dev
conversation = kindgi.conversations.open( agent_id="acme.order-desk", agent_version="0.1.0", title="Order A-1002", participant_id="customer-4411",)print(conversation.id, conversation.status, conversation.turn_count)
kindgi.runs.start( agent="acme.order-desk", input={"userMessage": "Where is order A-1002?", "conversationId": str(conversation.id)},)
closed = kindgi.conversations.close(str(conversation.id))print(closed.status, closed.turn_count)
try: kindgi.runs.start( agent="acme.order-desk", input={"userMessage": "One more thing…", "conversationId": str(conversation.id)}, )except ConflictError as err: print(err.server_code, err.message)
page = kindgi.conversations.list(agent_id="acme.order-desk", status="closed")print([str(c.id) for c in page.data])b3135493-47bd-4f78-90ac-fe0ff3e21e8d open 0closed 1conversation-closed Conversation "b3135493-47bd-4f78-90ac-fe0ff3e21e8d" is closed['b3135493-47bd-4f78-90ac-fe0ff3e21e8d', …]package com.acme.app;
import com.kindgi.client.ConflictException;import com.kindgi.client.Kindgi;import com.kindgi.client.models.Conversation;import com.kindgi.client.models.ConversationStatus;import com.kindgi.client.models.OpenConversationBody;import com.kindgi.client.models.StartRunBody;import com.kindgi.client.resources.ConversationsListParams;import java.util.Map;
public final class Close { public static void main(String[] args) { Kindgi kindgi = Kindgi.create(); // KINDGI_API_URL, KINDGI_API_TOKEN, or the running kindgi dev
Conversation conversation = kindgi.conversations().open(OpenConversationBody.builder() .agentId("acme.order-desk") .agentVersion("0.1.0") .title("Order A-1002") .participantId("customer-4411") .build()); System.out.println(conversation.id() + " " + conversation.status() + " " + conversation.turnCount());
kindgi.runs().start(StartRunBody.WithAgent.builder() .agent("acme.order-desk") .input(Map.of("userMessage", "Where is order A-1002?", "conversationId", conversation.id().toString())) .build());
Conversation closed = kindgi.conversations().close(conversation.id()); System.out.println(closed.status() + " " + closed.turnCount());
try { kindgi.runs().start(StartRunBody.WithAgent.builder() .agent("acme.order-desk") .input(Map.of("userMessage", "One more thing…", "conversationId", conversation.id().toString())) .build()); } catch (ConflictException e) { System.out.println(e.serverCode() + " " + e.getMessage()); }
var list = kindgi.conversations().list(ConversationsListParams.builder() .agentId("acme.order-desk") .status(ConversationStatus.CLOSED) .build()); System.out.println(list.data().stream().map(c -> c.id().toString()).toList()); }}581d051c-4619-4f9b-a3c8-56980ce4ac85 open 0closed 1conversation-closed Conversation "581d051c-4619-4f9b-a3c8-56980ce4ac85" is closed[581d051c-4619-4f9b-a3c8-56980ce4ac85]A turn on a closed conversation throws ConflictException; its
serverCode() is conversation-closed. list takes agentId and status
(ConversationStatus.OPEN or CLOSED), and scopeKind with scopeId for
one project's or org's.
package acme.app
import com.kindgi.client.{ConflictException, Kindgi}import com.kindgi.client.models.{ConversationStatus, OpenConversationBody, StartRunBody}import com.kindgi.client.resources.ConversationsListParamsimport scala.jdk.CollectionConverters._
object Close { def main(args: Array[String]): Unit = { val kindgi = Kindgi.create() // KINDGI_API_URL, KINDGI_API_TOKEN, or the running kindgi dev
val conversation = kindgi.conversations().open(OpenConversationBody.builder() .agentId("acme.order-desk") .agentVersion("0.1.0") .title("Order A-1002") .participantId("customer-4411") .build()) println(s"${conversation.id} ${conversation.status} ${conversation.turnCount}")
def turn(message: String) = kindgi.runs().start(StartRunBody.WithAgent.builder() .agent("acme.order-desk") .input(Map[String, AnyRef]("userMessage" -> message, "conversationId" -> conversation.id.toString).asJava) .build())
turn("Where is order A-1002?")
val closed = kindgi.conversations().close(conversation.id) println(s"${closed.status} ${closed.turnCount}")
try turn("One more thing…") catch { case e: ConflictException => println(s"${e.serverCode} ${e.getMessage}") }
val list = kindgi.conversations().list(ConversationsListParams.builder() .agentId("acme.order-desk") .status(ConversationStatus.CLOSED) .build()) println(list.data.asScala.map(_.id.toString).mkString("[", ", ", "]")) }}0c9b03fb-be50-4076-be4f-7c019c3bc634 open 0closed 1conversation-closed Conversation "0c9b03fb-be50-4076-be4f-7c019c3bc634" is closed[0c9b03fb-be50-4076-be4f-7c019c3bc634, …]A turn on a closed conversation throws ConflictException; its serverCode
is conversation-closed. list takes agentId and status
(ConversationStatus.OPEN or CLOSED), and scopeKind with scopeId for
one project's or org's.
openpins the agent and its version (below);titledefaults toUntitled conversation. A conversation that a turn opens takes the agent's name as its title.participantIdis kept on the conversation. A first turn can set it too, withparticipantIdin its input. Every turn in it runs as that participant, which only they (by their user id) or someone withwriteon the project may do, as reading their memory takes. Anyone else gets403 participant-not-allowed.- Who may add a turn: with a participant, the rule above. Without one,
whoever opened the conversation, or someone with
writeon its project, an editor or above. Reading the conversation isn't enough. Anyone else gets403 permission-denied. - A turn into a conversation that doesn't exist is refused with
404 conversation-not-foundbefore it starts, withwait: falsetoo. If you can't read the conversation's project, you get that same404in place of the409 conversation-project-mismatch(below) or the403 permission-denied. - Who opened it:
openedBy(user:<id>orservice_account:<id>) is the caller who opened it, or, for one a turn opened, whoever the run acts for. It's set by the server, never from the body.list({ openedBy: 'me' })(?openedBy=me,opened_by="me"in Python,--opened-by=mein the CLI) lists only your own, and every page says so, asopenedBy: "me". An older runtime ignores it and lists everyone's, with noopenedByon the page; the CLI stops instead, and names the release the runtime needs.- Conversations an older runtime opened record no opener, and aren't listed by it.
- It's a filter, not privacy: anyone who may read the project reads its conversations.
closeis safe to repeat: closing a closed conversation returns it as it is.listfilters by agent and bystatus(openorclosed), newest first;getfetches one conversation. From the CLI:kindgi conversations list --status=closed.- A comparison's replays have
conversations too, and lists leave them out unless you ask:
replays: 'include'or'only'(--replays=includeor--replays=onlyin the CLI). A replay's conversation hasreplayOfandevalRunIdin itsmetadata. - A conversation is in a project. A run opens it in the run's project, and
opentakes aprojectId, one of your tenant's projects, which takeswriteon that project, as a run naming one does. Without one, it's the agent's own project, as for a run. - Every later turn runs in the conversation's project. A turn that names
another
projectIdis refused with409 conversation-project-mismatch: name the conversation's project, or leaveprojectIdout. listtakes ascopefor one project's conversations, or every project's in an org:{ kind: 'project', projectId }or{ kind: 'org', orgId }. In Python, it'sscope_kind="project", scope_id=…. A conversation stored without a project, by an older runtime, is listed only without a scope.
From the CLI, where <conversation-id> is the id open prints:
pnpm exec kindgi conversations open acme.order-desk 0.1.0 --title="Order A-1002" --participant=customer-4411pnpm exec kindgi runs start --agent=acme.order-desk --agent-version=0.1.0 --input='{"userMessage":"Where is order A-1002?","conversationId":"<conversation-id>"}'pnpm exec kindgi conversations messages <conversation-id> # oldest firstpnpm exec kindgi conversations get <conversation-id>pnpm exec kindgi conversations close <conversation-id>uv run kindgi conversations open acme.order-desk 0.1.0 --title="Order A-1002" --participant=customer-4411uv run kindgi runs start --agent=acme.order-desk --agent-version=0.1.0 --input='{"userMessage":"Where is order A-1002?","conversationId":"<conversation-id>"}'uv run kindgi conversations messages <conversation-id> # oldest firstuv run kindgi conversations get <conversation-id>uv run kindgi conversations close <conversation-id>./kindgiw conversations open acme.order-desk 0.1.0 --title="Order A-1002" --participant=customer-4411./kindgiw runs start --agent=acme.order-desk --agent-version=0.1.0 --input='{"userMessage":"Where is order A-1002?","conversationId":"<conversation-id>"}'./kindgiw conversations messages <conversation-id> # oldest first./kindgiw conversations get <conversation-id>./kindgiw conversations close <conversation-id>./kindgiw conversations open acme.order-desk 0.1.0 --title="Order A-1002" --participant=customer-4411./kindgiw runs start --agent=acme.order-desk --agent-version=0.1.0 --input='{"userMessage":"Where is order A-1002?","conversationId":"<conversation-id>"}'./kindgiw conversations messages <conversation-id> # oldest first./kindgiw conversations get <conversation-id>./kindgiw conversations close <conversation-id>Over HTTP, these are POST /v1/conversations, GET /v1/conversations,
GET /v1/conversations/{id}, POST /v1/conversations/{id}/close and
GET /v1/conversations/{id}/messages.
Remove a conversation
Section titled “Remove a conversation”Unregistering a conversation removes it from every read, list and recall:
pnpm exec kindgi conversations unregister <conversation-id>uv run kindgi conversations unregister <conversation-id>./kindgiw conversations unregister <conversation-id>./kindgiw conversations unregister <conversation-id>It prints the conversation, now with unregisteredAt:
{ "id": "a74b27e5-9166-4b16-badd-258424f5ae20", "tenantId": "9f1543c9-7b93-46ff-a5ff-205093a148bb", "agentId": "acme.desk", "agentVersion": "1.0.0", "title": "acme.desk", "participantId": "end-7", "projectId": "aa0cf701-a8f7-40e5-b5d9-bec1a1cb1723", "scope": { "tenantId": "9f1543c9-7b93-46ff-a5ff-205093a148bb" }, "status": "open", "openedAt": "2026-10-09T13:30:24.826Z", "unregisteredAt": "2026-10-09T13:30:39.043Z", "turnCount": 1, "lastMessageAt": "2026-10-09T13:30:32.233Z"}From then on, no read, list or recall returns it, and it takes no more turns.
- With authorization on, unregistering
takes
writeon the conversation's project, an editor or above. Anyone else is refused with403 permission-denied, and the conversation stays. - An id that doesn't exist, or a conversation already unregistered,
answers
404. - Its messages stay in the database until a retention policy on
conversationpurges them, as Keep and purge deleted data describes. - In your app, it's
conversations.unregister. Over HTTP, it'sPOST /v1/conversations/{id}/unregister.
To clear a person's words now, from every conversation and run that held them, erase their data.
A conversation keeps its agent version
Section titled “A conversation keeps its agent version”A conversation is pinned to the agent version it was opened with, and its
turns run that version. With kindgi dev, only the current version of an
agent is registered, so after you change the agent's version, the next turn
of an open conversation is refused:
Error [not-found]: The conversation was opened with agent "acme.order-desk@0.1.0", which is no longer registered.A turn that names another version is refused too. That's
--agent-version=0.2.0 in the CLI, agentVersion in runs.start, or
agent_version in Python:
Error [agent-version-mismatch]: Conversation opened with acme.order-desk@0.1.0; invoked with acme.order-desk@0.2.0On a runtime you deploy to, where both versions are registered, the
conversation's turns go on with its version. With kindgi dev, start a new
conversation. Bump the version when a change would break conversations under
way, not on every edit.
Approvals in a conversation
Section titled “Approvals in a conversation”A conversation can make a person approve each turn once it runs long, or before a tool runs, as Ask before a long conversation continues and Ask before a tool runs show.