Store bot conversations, report model work and read an Assistant Cloud project from a backend with an API key.
A server or bot can write the same threads, messages and run reports as a browser runtime. Use an API key on the backend, identify the end user and workspace for every conversation, and keep your bot's own conversation id in external_id instead of maintaining a Cloud id map.
The API key is a server credential. Do not put it in a browser or mobile app.
Create the API key client
import { AssistantCloud } from "assistant-cloud";
export function createCloud(userId: string, workspaceId: string) {
return new AssistantCloud({
baseUrl: "https://backend.assistant-api.com",
apiKey: process.env.ASSISTANT_API_KEY!,
userId,
workspaceId,
});
}The client sends these headers on every API-key request.
| Header | Meaning |
|---|---|
Authorization: Bearer <api-key> | Authenticates the project API key. API keys are accepted on https://backend.assistant-api.com. |
Aui-User-Id: <user-id> | Identifies the end user who owns the conversation. A newly stored user message is what makes that user active for the current usage period. |
Aui-Workspace-Id: <workspace-id> | The workspace the request acts in: thread and message routes read and write inside it, while the project read routes require it and still read the whole project. It is required for API key calls other than trace export. |
The client attaches these headers for its methods. The HTTP examples below show the idempotency fields exactly, so they are also the pattern for a bot written in another language.
Store one bot conversation
Use a stable value such as a chat platform's conversation id as the thread external_id. Set upsert: true, and concurrent deliveries of the same new conversation receive the same Cloud thread. No bot-side mapping from external id to Cloud thread id is needed.
Create or find the thread
POST /v1/threads HTTP/1.1
Host: backend.assistant-api.com
Authorization: Bearer <api-key>
Aui-User-Id: user_123
Aui-Workspace-Id: workspace_acme
Content-Type: application/json
{
"last_message_at": "2026-09-17T09:30:00.000Z",
"external_id": "slack-C0123-1758100200.000100",
"upsert": true
}{ "thread_id": "thread_0qzof3jPoDwr7K3agyJN3D4U" }The endpoint answers 201 when it creates the thread and 200 when it finds the workspace's existing thread with that external id. Without upsert: true, external_id is not unique and duplicate threads are allowed.
Store the user's message
POST /v1/threads/thread_0qzof3jPoDwr7K3agyJN3D4U/messages HTTP/1.1
Host: backend.assistant-api.com
Authorization: Bearer <api-key>
Aui-User-Id: user_123
Aui-Workspace-Id: workspace_acme
Content-Type: application/json
{
"parent_id": null,
"external_id": "slack-1758100200.000100-user",
"format": "ai-sdk/v6",
"content": {
"role": "user",
"parts": [{ "type": "text", "text": "Where is my order?" }]
}
}{ "message_id": "msg_01HpuFBSuyKBf6YN5ljVXODa" }The user message records the active user after a new write. Repeating the same external_id with the same content returns 200 without a second insert, so it does not count that user again.
Store the assistant's reply
POST /v1/threads/thread_0qzof3jPoDwr7K3agyJN3D4U/messages HTTP/1.1
Host: backend.assistant-api.com
Authorization: Bearer <api-key>
Aui-User-Id: user_123
Aui-Workspace-Id: workspace_acme
Content-Type: application/json
{
"parent_id": null,
"parent_external_id": "slack-1758100200.000100-user",
"external_id": "slack-1758100200.000100-assistant",
"format": "ai-sdk/v6",
"content": {
"role": "assistant",
"parts": [{ "type": "text", "text": "Your order is on its way." }]
}
}{ "message_id": "msg_1TqGz6mYuwqf7Aa3TLiCv29Z" }parent_external_id is resolved inside this thread and takes precedence over parent_id. The assistant reply can therefore point to the user message without the bot remembering its Cloud message id.
Report the model run
POST /v1/runs HTTP/1.1
Host: backend.assistant-api.com
Authorization: Bearer <api-key>
Aui-User-Id: user_123
Aui-Workspace-Id: workspace_acme
Content-Type: application/json
{
"thread_id": "thread_0qzof3jPoDwr7K3agyJN3D4U",
"message_id": "msg_1TqGz6mYuwqf7Aa3TLiCv29Z",
"status": "completed",
"model_id": "gpt-6-luna",
"provider": "openai",
"input_tokens": 48,
"output_tokens": 12,
"duration_ms": 742
}{ "run_id": "run_0gBZldfKjJkeqnGBVuEWBGhP" }| Report field | Why it matters |
|---|---|
thread_id and status | Required. They attach the run to the conversation and say whether it completed, was incomplete or failed. |
message_id | Connects the run to the reply it produced. It must belong to thread_id. |
model_id and provider | Populate the model and provider dimensions. |
Token counts, duration_ms and first_token_ms | Populate usage and timing when your backend measured them. |
error, error_code and outcome_type | Describe an incomplete or failed run. |
trace_id | A 32 character lowercase hexadecimal trace id lets a client report merge with server spans. |
environment, release and tags | Add deployment and filter dimensions. |
Storing the user message queues title generation; the report does not. The title is a later background write to the thread, so it is not in the message response either. See Thread titles and Run reports.
Send the same calls from another language
The four requests above are plain HTTP. A Python, Go or Java service sends the same URL, JSON bodies and headers. Content-Type: application/json is required for JSON request bodies. The thread and message external ids make retries safe when the body and message role stay the same.
An external_id repeat with a different body updates that message and returns 200. A repeat with a different content.role fails instead, which prevents a user message from becoming an assistant reply or the reverse.
This Python script performs the same sequence and carries the returned ids into each later request.
import os
import requests
session = requests.Session()
session.headers.update(
{
"Authorization": f"Bearer {os.environ['ASSISTANT_API_KEY']}",
"Aui-User-Id": "user_123",
"Aui-Workspace-Id": "workspace_acme",
"Content-Type": "application/json",
}
)
base_url = "https://backend.assistant-api.com"
thread_response = session.post(
f"{base_url}/v1/threads",
json={
"last_message_at": "2026-09-17T09:30:00.000Z",
"external_id": "slack-C0123-1758100200.000100",
"upsert": True,
},
timeout=30,
)
thread_response.raise_for_status()
thread_id = thread_response.json()["thread_id"]
user_response = session.post(
f"{base_url}/v1/threads/{thread_id}/messages",
json={
"parent_id": None,
"external_id": "slack-1758100200.000100-user",
"format": "ai-sdk/v6",
"content": {
"role": "user",
"parts": [{"type": "text", "text": "Where is my order?"}],
},
},
timeout=30,
)
user_response.raise_for_status()
user_message_id = user_response.json()["message_id"]
assistant_response = session.post(
f"{base_url}/v1/threads/{thread_id}/messages",
json={
"parent_id": user_message_id,
"external_id": "slack-1758100200.000100-assistant",
"format": "ai-sdk/v6",
"content": {
"role": "assistant",
"parts": [{"type": "text", "text": "Your order is on its way."}],
},
},
timeout=30,
)
assistant_response.raise_for_status()
assistant_message_id = assistant_response.json()["message_id"]
run_response = session.post(
f"{base_url}/v1/runs",
json={
"thread_id": thread_id,
"status": "completed",
"message_id": assistant_message_id,
"model_id": "gpt-6-luna",
"provider": "openai",
"input_tokens": 48,
"output_tokens": 12,
"duration_ms": 742,
},
timeout=30,
)
run_response.raise_for_status()The same body shapes work from Go or Java.
Export traces instead of reports
If your backend already emits OpenTelemetry, it can export traces without separately posting a run report. Send OTLP JSON or protobuf to POST /v1/traces with Authorization: Bearer <api-key> and an OTLP Content-Type. Trace export is API-key only; Aui-Workspace-Id is optional for that route and defaults to its trace workspace.
Each imported trace becomes a run. Set gen_ai.conversation.id to the Cloud thread id to link it to the conversation. A matching 32 character trace id also merges a later client report into the imported run. See Traces.
Read a project
The project read API is API-key only and reads across every workspace in the project. Use it for operational views that do not belong to one end user.
| Need | Route |
|---|---|
| Threads and their messages | GET /v1/projects/threads and GET /v1/projects/threads/{thread_id}/messages |
| Runs and one run's spans | GET /v1/projects/runs and GET /v1/projects/runs/{run_id} |
| Usage | GET /v1/projects/usage/daily and GET /v1/projects/usage/period |
| Scores | GET /v1/projects/scores |
curl "https://backend.assistant-api.com/v1/projects/threads?limit=20" \
-H "Authorization: Bearer $ASSISTANT_API_KEY" \
-H "Aui-User-Id: user_123" \
-H "Aui-Workspace-Id: workspace_acme"POST /v1/mcp offers read-only tools for runs, threads, scores, topics and usage. It also requires an API key and Aui-Workspace-Id. See API and MCP.
Troubleshooting
| What you see | Why | What to do |
|---|---|---|
403 when using an API key | The key was sent to a project frontend host instead of https://backend.assistant-api.com. | Send API-key traffic to the backend host. |
403 with Invalid Aui-Workspace-Id header | A non-trace API-key request omitted the workspace header or supplied an invalid value. | Send a nonempty workspace id with no whitespace or control characters. |
402 with plan_limit_reached | A new user message would exceed the active-user cap for the current usage period. | Wait for the next period or change the project's plan. Repeats of an existing message do not trigger this check. |
409 with Message role cannot change | An existing message external_id was repeated with a different content.role. | Keep each external id bound to its original role. Use a new external id for another message. |
| A trace is not attached to its thread | gen_ai.conversation.id did not equal a Cloud thread id in the project. | Set it to the thread_id returned by the create or upsert request. |