# Hold a conversation

> Continue a conversation with an agent turn by turn, limit the history its model sees, read the history, and close the 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

- **An agent with a tool,** on a runtime such as `kindgi dev`, with Kindgi 0.1's CLI. This guide uses `acme.order-desk` and its `acme.lookup-order` tool, from [Write an agent](https://docs.kindgi.com/v0.1.8/guides/agents/write-an-agent.md).
- **A registered model** for answers worth reading. Without one, `dev-echo` answers.
- **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_URL` and `KINDGI_API_TOKEN`. With `kindgi dev`, they're the URL and token it prints, also in the pack's `.kindgirc.json`.

## 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:

- **TypeScript**

  `scripts/chat.ts`

  ```ts
  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));
  ```

  ```sh
  node scripts/chat.ts
  ```

  ```text
  2 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."
  ```

- **Python**

  `scripts/chat.py`

  ```python
  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)
  ```

  ```sh
  uv run python scripts/chat.py
  ```

  ```text
  2 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

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](https://docs.kindgi.com/v0.1.8/start/coding-agents.md)). For reference, it adds `historyLimit` to the agent's definition:

- **TypeScript**

  ```ts excerpt
  // in agents/order-desk/index.ts
    conversationPolicy: { historyLimit: 20 },
  ```

- **Python**

  ```python excerpt
  # in agents/order_desk.py
      conversation_policy={"historyLimit": 20},
  ```

- **Java**

  ```java excerpt
  // in src/main/java/acme/agents/OrderDesk.java
        .set("conversationPolicy", Map.of("historyLimit", 20))
  ```

- **Scala**

  ```scala excerpt
  // 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

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:

- **TypeScript**

  `scripts/close.ts`

  ```ts
  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));
  ```

  ```text excerpt
  88356874-400d-4bcd-9708-63ab51f88dd3 open 0
  closed 1
  conversation-closed Conversation "88356874-400d-4bcd-9708-63ab51f88dd3" is closed
  [
    '88356874-400d-4bcd-9708-63ab51f88dd3',
    …
  ]
  ```

- **Python**

  `scripts/close.py`

  ```python
  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])
  ```

  ```text excerpt
  b3135493-47bd-4f78-90ac-fe0ff3e21e8d open 0
  closed 1
  conversation-closed Conversation "b3135493-47bd-4f78-90ac-fe0ff3e21e8d" is closed
  ['b3135493-47bd-4f78-90ac-fe0ff3e21e8d', …]
  ```

- **Java**

  `src/main/java/com/acme/app/Close.java`

  ```java
  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());
    }
  }
  ```

  ```text
  581d051c-4619-4f9b-a3c8-56980ce4ac85 open 0
  closed 1
  conversation-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.

- **Scala**

  `src/main/scala/acme/app/Close.scala`

  ```scala
  package acme.app

  import com.kindgi.client.{ConflictException, Kindgi}
  import com.kindgi.client.models.{ConversationStatus, OpenConversationBody, StartRunBody}
  import com.kindgi.client.resources.ConversationsListParams
  import 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("[", ", ", "]"))
    }
  }
  ```

  ```text excerpt
  0c9b03fb-be50-4076-be4f-7c019c3bc634 open 0
  closed 1
  conversation-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.

* `open` pins the agent and its version (below); `title` defaults to `Untitled conversation`. A conversation that a turn opens takes the agent's name as its title.
* `participantId` is kept on the conversation. A first turn can set it too, with `participantId` in its input. Every turn in it runs as that participant, which only they (by their user id) or someone with `write` on the project may do, as reading their memory takes. Anyone else gets `403 participant-not-allowed`.
* `close` is safe to repeat: closing a closed conversation returns it as it is.
* `list` filters by agent and by `status` (`open` or `closed`), newest first; `get` fetches one conversation. From the CLI: `kindgi conversations list --status=closed`.
* **A [comparison](https://docs.kindgi.com/v0.1.8/guides/evals/compare-an-agent-version.md)'s replays** have conversations too, and lists leave them out unless you ask: `replays: 'include'` or `'only'` (`--replays=include` or `--replays=only` in the CLI). A replay's conversation has `replayOf` and `evalRunId` in its `metadata`.
* A conversation is in a project. A run opens it in the run's project, and `open` takes a `projectId`, one of your tenant's projects. Without one, it's the agent's own project, as for a run.
* `list` takes a `scope` for one project's conversations, or every project's in an org: `{ kind: 'project', projectId }` or `{ kind: 'org', orgId }`. In Python, it's `scope_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:

- **TypeScript**

  ```sh
  pnpm exec kindgi conversations open acme.order-desk 0.1.0 --title="Order A-1002" --participant=customer-4411
  pnpm 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 first
  pnpm exec kindgi conversations get <conversation-id>
  pnpm exec kindgi conversations close <conversation-id>
  ```

- **Python**

  ```sh
  uv run kindgi conversations open acme.order-desk 0.1.0 --title="Order A-1002" --participant=customer-4411
  uv 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 first
  uv run kindgi conversations get <conversation-id>
  uv run kindgi conversations close <conversation-id>
  ```

- **Java**

  ```sh
  ./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>
  ```

- **Scala**

  ```sh
  ./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

Unregistering a conversation removes it from every read, list and recall:

- **TypeScript**

  ```sh
  pnpm exec kindgi conversations unregister <conversation-id>
  ```

- **Python**

  ```sh
  uv run kindgi conversations unregister <conversation-id>
  ```

- **Java**

  ```sh
  ./kindgiw conversations unregister <conversation-id>
  ```

- **Scala**

  ```sh
  ./kindgiw conversations unregister <conversation-id>
  ```

It prints the conversation, now with `unregisteredAt`:

```json
{
  "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](https://docs.kindgi.com/v0.1.8/guides/agents/give-an-agent-memory.md#recall-earlier-conversations) returns it, and it takes no more turns.

- **With [authorization](https://docs.kindgi.com/v0.1.8/deploy/authorization.md) on,** unregistering takes `write` on the conversation's project, an editor or above. Anyone else is refused with `403 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 `conversation` purges them, as [Keep and purge deleted data](https://docs.kindgi.com/v0.1.8/deploy/retention.md) describes.
- **In your app,** it's `conversations.unregister`. Over HTTP, it's `POST /v1/conversations/{id}/unregister`.

To clear a person's words now, from every conversation and run that held them, [erase their data](https://docs.kindgi.com/v0.1.8/guides/agents/erase-a-persons-data.md).

## 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:

```text
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:

```text
Error [agent-version-mismatch]: Conversation opened with acme.order-desk@0.1.0; invoked with acme.order-desk@0.2.0
```

On 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

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](https://docs.kindgi.com/v0.1.8/guides/approvals/ask-after-n-turns.md) and [Ask before a tool runs](https://docs.kindgi.com/v0.1.8/guides/approvals/ask-before-a-tool-runs.md) show.

## Next

- [Give an agent memory](https://docs.kindgi.com/v0.1.8/guides/agents/give-an-agent-memory.md)
- [Ask before a long conversation continues](https://docs.kindgi.com/v0.1.8/guides/approvals/ask-after-n-turns.md)
- [Erase a person's data](https://docs.kindgi.com/v0.1.8/guides/agents/erase-a-persons-data.md)
