Conversations
A Conversation is a multi-party dialogue within a project: ordered messages, each with a role (user or assistant) and an optional authoring Actor.
Overview
Each message references a Document, has a role, and optionally an Actor author. Ids are prefixed conv_; the internal primary key is never returned.
See the Permissions Reference for the IAM action strings for this module.
Related Tutorials
- Chat with an LLM - Step 7 (View the conversation history)
- Connect Third-Party LLMs - Step 6 (Start a conversation)
- Debug Session, Generation, and Trace History - Step 4 (Retrieve the full session message timeline)
Data Model
Conversation
| Field | Type | Description |
|---|---|---|
id | string | Public identifier prefixed with conv_ |
project_id | string | ID of the owning project |
name | string | Optional human-readable title for the conversation |
status | string | Conversation status: open or closed |
actor_id | string | Optional ID of the Actor who owns this conversation (nullable) |
retrieval | string | embed, none, or null to inherit the project — see Retrieval |
tags | object | Free-form string tags |
created_at | string | ISO 8601 creation timestamp |
updated_at | string | ISO 8601 last-updated timestamp |
actor_id is the conversation owner (typically the external contact who initiated it, e.g. a WhatsApp contact), set at creation and distinct from message authorship; other actors can still send messages. List distinct participants with GET /actors?conversation_id=....
Conversation Message
| Field | Type | Description |
|---|---|---|
document_id | string | ID of the Document attached as a message |
role | string | Role of the message: user or assistant — system is refused with 400 SYSTEM_MESSAGE_NOT_ALLOWED |
actor_id | string | null | Optional ID of the Actor who authored the message; null for messages not tied to an actor |
agent_id | string | null | Optional ID of the Agent that generated this message; null for non-generated messages |
position | integer | Zero-based position of the message in the conversation |
metadata | object | null | Caller-owned annotations on the message (e.g. phone, channel), stored as sent. Never read by the platform and never part of the model's input — see Tags and metadata |
content | string | Full text content of the message (read from the underlying document) |
The pair (conversation_id, position) is uniquely indexed. See Message ordering for insertion semantics.
Key Concepts
Actors, Agents, and Chats
Actors track authorship; generation is triggered by passing agent_id to POST /conversations/:id/generate and needs no actor. Linking and deletion rules: Agent and Chat Linking. Deleting an Actor is blocked while any conversation message references it.
Messages
Messages are ordered Document references with a role and optional actor_id. A document may appear once per conversation; adding it twice returns 409 Conflict.
role: "system" is refused with 400 SYSTEM_MESSAGE_NOT_ALLOWED: stored history feeds agent generations, so a system entry would let conversation data rewrite the agent's prompt. System content belongs in the agent's instructions or the actor persona.
Listed messages carry the document's full content, role, actor_id, and agent_id (set on assistant messages produced by POST /conversations/:id/generate, null otherwise). Example: Chat with an LLM - Step 7 (View the conversation history).
Removing a message also deletes its Document and File, and deleting a conversation deletes every turn it holds.
Retrieval
Each message is stored as a Document under
/.system/conversations/{conversation_id}/{document_id}.txt — see
the reserved root. retrieval decides
whether those turns are embedded for vector search:
| Value | Effect |
|---|---|
none | Turns are stored and chunked, so they stay readable by id, in the message list, and through the full-text channel of knowledge search. No embedding is made or billed. |
embed | Turns are embedded as well, so the vector channel ranks them. |
null | Inherits the project's default_conversation_retrieval (itself none by default). |
A turn is embedded because someone asked for the conversation to be
retrievable, never merely because it was said. Setting retrieval to embed
on PATCH /conversations/{conversation_id}
embeds the turns already in the conversation, so the whole conversation becomes
retrievable rather than only what is said next; setting it back to none stops
new turns being embedded and leaves the vectors already paid for in place.
Either way a message document is the runtime's: it is read-only through the
document API, and an unfiltered document list or a bare knowledge query leaves
it out unless the request names /.system/conversations/.
What a turn is stamped with
Every message document carries the provenance of the turn in its tags, written by the platform:
| Key | Value |
|---|---|
system.conversation | This conversation's id |
system.actor | The conversation's owner (actor_id), absent when it has none |
system.agent | The agent that generated the message, absent on a user turn |
system.role | user or assistant |
system.actor is the owner rather than the message's author, because the owner
is stable for the whole conversation and is what an isolation rule names;
per-message authorship stays on the message's own actor_id.
These keys are read-only. A write naming any key starting with system. — on a
create, a tag sub-endpoint or a formation template — is
refused with 400 RESERVED_TAG_KEY: a caller who could set system.actor
could make their own rows answer to another actor's filter.
They are ordinary tags otherwise, so one filter selects an actor's turns:
{ "query": "refund policy", "tags": { "system.actor": "actor_V1StGXR8Z5jdHi6B" } }
Naming a system.* key is also what reaches
the reserved root, so the filter needs no
document_paths beside it. A memory a
memory rule extracted from a conversation carries
the same system.conversation and system.actor, so that one filter returns
the actor's raw turns and the facts distilled from them together. To fence one
actor's turns from another, put the same key in an IAM condition — see
IAM — Tags.
Tags
Key-value string pairs managed via the tag sub-endpoints and matched by soat:ResourceTag/<key>. GET /api/v1/conversations filters by pair with ?tags=key:value (repeatable, all must match). See IAM — Tags.
Tool-call chains (invocations and results alongside the final text) are preserved internally so later turns see the full exchange; this state is separate from metadata and never returned.
Message ordering
No two messages share a slot:
- Append (default): omitted
positionwrites atMAX(position) + 1. - Insert between: a colliding
positionshifts that message and all after it up by one in a single transaction. - Concurrent writes at the same
positionrace on the unique index; the loser gets409 Conflictand retries.
Generating the Next Message
Any Agent can generate the next message from the history. Provider-backed example: Connect Third-Party LLMs - Step 6 (Start a conversation).
POST /api/v1/conversations/:id/generate?wait=true
{ "agent_id": "agent_...", "stream": false }
By default the call runs in the background and returns 202 Accepted ({ "status": "accepted", "conversation_id": "conv_..." }); poll GET /conversations/:id/messages for the reply. The agent is resolved synchronously, so an unknown agent_id is a 404. It is one of the conversation's project's own agents or one another project shares with it; a shared agent's turn is recorded and metered in the conversation's project.
?wait=true blocks and returns the result inline; it is required to observe requires_action (client tools). See Synchronous & Asynchronous Execution for the platform-wide wait contract.
Flow (with ?wait=true):
-
Load messages ordered by
position. -
Compose the system prompt from the agent's
instructions, plus a line naming the agent and telling it to reply as itself, without a speaker label. -
Map
role: 'assistant'messages to assistant turns; all others to user turns, prefixed[<speaker name>]:. A message'smetadatais not part of the turn — the model seesmessageand nothing else the caller attached. -
Dispatch to the Agents module, including agent tools and the
requires_actionclient-tool flow. -
On
completed, a new Document is attached as the next message withrole: 'assistant'. The response includes:content— the generated text (always astring).message— the persistedConversationMessageRecord(document_id,role,actor_id,agent_id,position,content);agent_idis the generating agent.generation_idandtrace_id.model— the model used.
- CLI
- SDK
- curl
soat generate-conversation-message \--conversation-id conv_01 \--agent-id agent_01 \--wait trueconst { data, error } = await soat.conversations.generateConversationMessage({path: { conversation_id: 'conv_01' },query: { wait: true },body: { agent_id: 'agent_01' },});if (error) throw new Error(JSON.stringify(error));// data.content is always the AI-generated text when data.status === 'completed'const responseText = data?.content;curl -X POST "https://api.example.com/api/v1/conversations/conv_01/generate?wait=true" \-H "Authorization: Bearer <token>" \-H "Content-Type: application/json" \-d '{ "agent_id": "agent_01" }' -
On
requires_action(client tools), no message is persisted yet. Submit outputs viaPOST /agents/:id/generate/:generation_id/tool-outputs; the message is persisted on completion.
Concurrency
Generate calls hold a per-conversation advisory lock, so concurrent calls on one conversation are serialized and never race for a position.
Streaming
With "stream": true the response is text/event-stream. The message is persisted only after the stream completes; partial streams produce none. The final event carries document_id, generation_id, and trace_id.
Tool Context
POST /api/v1/conversations/:id/generate accepts tool_context, forwarded verbatim to the agent generation; see Tool Context.
Filtering by Actor
GET /conversations?actor_id=... lists conversations where the actor authored at least one message (an EXISTS join on conversation_messages, costlier than the default listing).
Status
open or closed, default open; change via PATCH /conversations/:id.
Examples
Create a conversation and add a message
- CLI
- SDK
- curl
soat create-conversation --project-id proj_ABC --name "Support Thread"
soat add-conversation-message \
--conversation-id conv_01 \
--message "Hello, I need help." \
--role user
// SDK
import { SoatClient } from '@soat/sdk';
const soat = new SoatClient({
baseUrl: 'https://api.example.com',
token: 'sk_...',
});
const { data: conv } = await soat.conversations.createConversation({
body: { project_id: 'proj_ABC', name: 'Support Thread' },
});
const { data: msg } = await soat.conversations.addConversationMessage({
path: { conversation_id: conv.id },
body: { message: 'Hello, I need help.', role: 'user' },
});
curl -X POST https://api.example.com/api/v1/conversations \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"project_id": "proj_ABC", "name": "Support Thread"}'
curl -X POST https://api.example.com/api/v1/conversations/conv_01/messages \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"message": "Hello, I need help.", "role": "user"}'
Generate the next message
- CLI
- SDK
- curl
soat generate-conversation-message --wait true \
--conversation-id conv_01 \
--agent-id agent_01
// SDK
const { data: reply } = await soat.conversations.generateConversationMessage({
path: { conversation_id: 'conv_01' },
query: { wait: true },
body: { agent_id: 'agent_01' },
});
curl -X POST https://api.example.com/api/v1/conversations/conv_01/generate?wait=true \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"agent_id": "agent_01"}'