# Servers and bots
URL: /docs/cloud/servers

Store bot conversations, report model work and read an Assistant Cloud project from a backend with an API key.

> For AI agents: a documentation index is available at [llms.txt](/llms.txt). Use `.md` for canonical markdown pages; `.mdx` is kept as a backwards-compatible alias on supported URL paths.

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](/docs/cloud/thread-titles) and [Run reports](/docs/cloud/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](/docs/cloud/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](/docs/cloud/api) and [MCP](/docs/cloud/api/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.                                              |