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; any other value is 400 VALIDATION_FAILED |
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, any other value with 400 VALIDATION_FAILED |
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 | 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) |
idempotency_key | string | null | The idempotency key the message was posted with (session messages take one); null when none was sent. A channel that posts each inbound message under its provider's message id finds that message's row by this key |
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, idempotency_key, and agent_id (set on assistant messages produced by POST /conversations/:id/generate, null otherwise). They come in position order, ascending by default; ?order=desc reads the transcript from its end. 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.
Each message emits a webhook event named by its document_id: conversation_messages.created when added, conversation_messages.generated when an agent's reply is saved, and conversation_messages.deleted when removed. data is the message as listed, with conversation_id beside it; a generated reply adds generation_id and trace_id, and a deletion carries the message as it stood.
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 to one conversation are serialized: each resolves its
positionin turn, so every one is stored. A write that still finds itspositiontaken answers409 CONVERSATION_POSITION_CONFLICTand stores nothing; retry it.
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,idempotency_key);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. When the turn completes, its reply is stored right after the last message the turn read and emitsconversation_messages.generated, as in step 5; that route answers the generation, not the stored message.
Concurrency
Nothing serializes generate calls on one conversation. Each turn reads the history as it stands when it starts, so concurrent turns answer the same history, and stores its reply right after the last message it read, so a message added while it runs stays after the reply. Message writes to one conversation are serialized, so a reply and the messages added while it ran are all stored (see Message ordering).
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 the conversations the actor owns (their actor_id). Message authorship does not match: list a conversation's participants with GET /actors?conversation_id=....
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"}'