Skip to main content

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.

Data Model​

Conversation​

FieldTypeDescription
idstringPublic identifier prefixed with conv_
project_idstringID of the owning project
namestringOptional human-readable title for the conversation
statusstringConversation status: open or closed; any other value is 400 VALIDATION_FAILED
actor_idstringOptional ID of the Actor who owns this conversation (nullable)
retrievalstringembed, none, or null to inherit the project — see Retrieval
tagsobjectFree-form string tags
created_atstringISO 8601 creation timestamp
updated_atstringISO 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​

FieldTypeDescription
document_idstringID of the Document attached as a message
rolestringRole of the message: user or assistant — system is refused with 400 SYSTEM_MESSAGE_NOT_ALLOWED, any other value with 400 VALIDATION_FAILED
actor_idstring | nullOptional ID of the Actor who authored the message; null for messages not tied to an actor
agent_idstring | nullOptional ID of the Agent that generated this message; null for non-generated messages
positionintegerZero-based position of the message in the conversation
metadataobjectCaller-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
contentstringFull text content of the message (read from the underlying document)
idempotency_keystring | nullThe 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:

ValueEffect
noneTurns 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.
embedTurns are embedded as well, so the vector channel ranks them.
nullInherits 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:

KeyValue
system.conversationThis conversation's id
system.actorThe conversation's owner (actor_id), absent when it has none
system.agentThe agent that generated the message, absent on a user turn
system.roleuser 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 position writes at MAX(position) + 1.
  • Insert between: a colliding position shifts that message and all after it up by one in a single transaction.
  • Concurrent writes to one conversation are serialized: each resolves its position in turn, so every one is stored. A write that still finds its position taken answers 409 CONVERSATION_POSITION_CONFLICT and 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):

  1. Load messages ordered by position.

  2. 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.

  3. Map role: 'assistant' messages to assistant turns; all others to user turns, prefixed [<speaker name>]: . A message's metadata is not part of the turn — the model sees message and nothing else the caller attached.

  4. Dispatch to the Agents module, including agent tools and the requires_action client-tool flow.

  5. On completed, a new Document is attached as the next message with role: 'assistant'. The response includes:

    • content — the generated text (always a string).
    • message — the persisted ConversationMessageRecord (document_id, role, actor_id, agent_id, position, content, idempotency_key); agent_id is the generating agent.
    • generation_id and trace_id.
    • model — the model used.
    soat generate-conversation-message \
    --conversation-id conv_01 \
    --agent-id agent_01 \
    --wait true
  6. On requires_action (client tools), no message is persisted yet. Submit outputs via POST /agents/:id/generate/:generation_id/tool-outputs. When the turn completes, its reply is stored right after the last message the turn read and emits conversation_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​

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

Generate the next message​

soat generate-conversation-message --wait true \
--conversation-id conv_01 \
--agent-id agent_01