Servers and bots

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

app/lib/cloud.ts
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.

HeaderMeaning
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

Request
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
}
Response
{ "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

Request
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?" }]
  }
}
Response
{ "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

Request
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." }]
  }
}
Response
{ "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

Request
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
}
Response
{ "run_id": "run_0gBZldfKjJkeqnGBVuEWBGhP" }
Report fieldWhy it matters
thread_id and statusRequired. They attach the run to the conversation and say whether it completed, was incomplete or failed.
message_idConnects the run to the reply it produced. It must belong to thread_id.
model_id and providerPopulate the model and provider dimensions.
Token counts, duration_ms and first_token_msPopulate usage and timing when your backend measured them.
error, error_code and outcome_typeDescribe an incomplete or failed run.
trace_idA 32 character lowercase hexadecimal trace id lets a client report merge with server spans.
environment, release and tagsAdd 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.

bot.py
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.

NeedRoute
Threads and their messagesGET /v1/projects/threads and GET /v1/projects/threads/{thread_id}/messages
Runs and one run's spansGET /v1/projects/runs and GET /v1/projects/runs/{run_id}
UsageGET /v1/projects/usage/daily and GET /v1/projects/usage/period
ScoresGET /v1/projects/scores
curl
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 seeWhyWhat to do
403 when using an API keyThe 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 headerA 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_reachedA 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 changeAn 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 threadgen_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.