Chats
LLM completions with optional persistent configuration, supporting both stateless and per-chat modes.
Overview
All completions run through a single endpoint, POST /chat/completions, which names exactly one target:
- Stateless (
ai_provider_id) — OpenAI-compatible; pass the full provider configuration on every request. No setup required. - Per-chat (
chat_id) — create a Chat resource once to store the AI provider, defaultinstructions, and model; then pass onlychat_idand themessagesarray per request.
The two are mutually exclusive, and a request naming neither — or both — is rejected with 400.
Both targets support SSE streaming via stream: true. To see a completion driven end to end through a provider-backed flow, follow Connect Third-Party LLMs - Step 6 (Start a conversation).
See the Permissions Reference for the IAM action strings for this module.
Related Tutorials
- Chat with an LLM - Step 3 (Create a local AI provider)
- Connect Third-Party LLMs - Step 6 (Start a conversation)
Data Model
Chat
| Field | Type | Description |
|---|---|---|
id | string | Public ID prefixed with chat_ |
project_id | string | Public ID of the owning project |
ai_provider_id | string | null | Public ID of the pinned AI provider, or null when the chat pins none and inherits its project's default_model_route_id |
name | string | Optional human-readable name |
instructions | string | Optional default system prompt applied to all completions — the same name an Agent uses |
model | string | Optional model override (falls back to provider's default_model) |
created_at | string | ISO 8601 creation timestamp |
updated_at | string | ISO 8601 last-updated timestamp |
Message
Each message in the messages array sent to the completions endpoint:
| Field | Type | Description |
|---|---|---|
role | user | assistant | Identifies the author of the message. system is refused — see System Instructions |
content | string | Text body (use this or document_id, not both) |
document_id | string | Public ID of a document — the server resolves its content before the call |
Key Concepts
System Instructions
System content never travels as a message — one rule, on every SOAT surface. On a completion it goes in the instructions request field — the same name everywhere: a completion request, a Chat, an Agent — and a role: "system" entry in messages is refused with 400 SYSTEM_MESSAGE_NOT_ALLOWED.
The server sends the field to the provider as its instructions argument, which is the only place the underlying AI SDK accepts it — allowSystemInMessages defaults to false there and throws, because a system message inside a caller-supplied array is a prompt-injection vector. SOAT's wire contract is the same contract.
The same rule everywhere else: an agent's system prompt is its instructions field (Agents), and a conversation's stored history carries only user and assistant turns (Conversations) — all three refuse a system entry with the same 400.
Per-chat override
A Chat stores instructions applied to every completion on it. A single call replaces them by supplying its own instructions. The Chat record is not modified.
The stored prompt applies only when the request carries none. The two are never merged: combining them would produce a prompt neither the chat nor the caller wrote.
AI Provider Resolution
For per-chat completions the AI provider is taken from the Chat record, and the pin must name a provider in the chat's own project — one from another project answers 400 AI_PROVIDER_NOT_FOUND, indistinguishably from an id that exists nowhere. A chat created without ai_provider_id pins none and resolves through its project's default_model_route_id instead, which gives its completions ordered provider failover; model cannot be combined with that (each route target names its own), and omitting the provider returns 400 when the project has no default.
For a stateless completion ai_provider_id is passed directly in the request body and is required — that call belongs to no chat, so there is no chat binding and no default to inherit. It is still scoped to a project: the provider's own — see Authorization.
See AI Providers for the full list of supported providers and how secrets are resolved. For a worked example of creating a provider the Chat can reference, see Chat with an LLM - Step 3 (Create a local AI provider).
Authorization
Both targets are gated on the same action, chats:CreateChatCompletion, each checked against the project the call belongs to:
| Target | Project the check runs against |
|---|---|
chat_id | the chat's project |
ai_provider_id | the AI provider's project |
A caller without the action on that project gets 403, before any provider call and before an SSE stream is opened — a refused streaming request is a JSON 403, never an error frame inside a 200 stream. An ai_provider_id that does not exist is still 404, which is resolved before the permission check.
Streaming
Set stream: true in the request body to receive an SSE stream. Each event contains a JSON object with a choices[0].delta.content chunk. The stream ends with data: [DONE].
Upstream provider errors
When the provider rejects the completion — an unavailable model, a refused credential — or cannot be reached, POST /api/v1/chat/completions answers 502 AI_PROVIDER_ERROR with the provider's own status and message in the error message:
{
"error": {
"code": "AI_PROVIDER_ERROR",
"message": "Provider returned 404: model \"gemini-2.0-flash\" not found"
}
}
This is the same mapping Agents generation applies, so probing which models a provider can actually serve gives an interpretable answer instead of a bare 500.
A streaming request cannot report this as a status code — its 200 and headers are written before the provider is called. The failure arrives as a terminal data: {"error": "..."} frame carrying the same message, and the stream then ends without a [DONE].
Document-Backed Messages
A message may carry a document_id instead of inline content. The server fetches that document and uses its content field as the message body. jq-based selection of tool output (the output_path behavior) is handled by Agents.
Examples
Create a chat
- CLI
- SDK
- curl
soat create-chat \
--project-id proj_ABC \
--ai-provider-id aip_abc123 \
--name "Support Assistant" \
--instructions "You are a helpful support assistant."
import { SoatClient } from '@soat/sdk';
const soat = new SoatClient({ baseUrl: 'https://api.example.com', token: 'sk_...' });
const { data, error } = await soat.chats.createChat({
body: {
project_id: 'proj_ABC',
ai_provider_id: 'aip_abc123',
name: 'Support Assistant',
instructions: 'You are a helpful support assistant.',
},
});
if (error) throw new Error(JSON.stringify(error));
curl -X POST https://api.example.com/api/v1/chats \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"project_id": "proj_ABC",
"ai_provider_id": "aip_abc123",
"name": "Support Assistant",
"instructions": "You are a helpful support assistant."
}'
Run a per-chat completion
Once a Chat is stored, run completions against it by passing chat_id and the messages array — the AI provider, instructions, and model come from the Chat record.
A Chat stores configuration, not conversation history: no message sent to or returned from a completion is persisted, so send the full messages array on every call.
- CLI
- SDK
- curl
soat create-chat-completion \
--chat_id chat_01 \
--messages '[{"role":"user","content":"What can you help me with?"}]'
const { data, error } = await soat.chats.createChatCompletion({
body: {
chat_id: 'chat_01',
messages: [{ role: 'user', content: 'What can you help me with?' }],
},
});
if (error) throw new Error(JSON.stringify(error));
curl -X POST https://api.example.com/api/v1/chat/completions \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"chat_id": "chat_01",
"messages": [{ "role": "user", "content": "What can you help me with?" }]
}'
Run a stateless completion
- CLI
- SDK
- curl
soat create-chat-completion \
--ai-provider-id aip_abc123 \
--instructions "You are a helpful assistant." \
--messages '[{"role":"user","content":"Hello!"}]'
const { data, error } = await soat.chats.createChatCompletion({
body: {
ai_provider_id: 'aip_abc123',
instructions: 'You are a helpful assistant.',
messages: [{ role: 'user', content: 'Hello!' }],
},
});
if (error) throw new Error(JSON.stringify(error));
curl -X POST https://api.example.com/api/v1/chat/completions \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"ai_provider_id": "aip_abc123",
"instructions": "You are a helpful assistant.",
"messages": [{ "role": "user", "content": "Hello!" }]
}'