Skip to main content

Threads over the API

Premium feature

A thread is one conversation with an agent. It holds the message history, so you do not resend prior turns — this is the main way the BasePeak.AI API differs from a stateless chat API.

One API key can drive as many threads as you like, concurrently and independently.

How state works here

OpenAI's APIBasePeak.AI
Where history livesIn your request — you resend every turnOn the server, in the thread
What you sendThe full messages arrayJust the new message
What identifies the conversationnothing; it is statelessthe thread ID

On /v1/chat/completions the consequence is worth stating plainly: history is not reconstructed from your messages array. Only the last user message is taken as the prompt. Sending ten prior turns does not give the agent ten turns of context — passing the thread ID does.

Starting a thread

Omit any thread reference and one is created for you. Both surfaces return the new ID in the X-BPAI-Thread-Id response header.

curl -sS -D headers.txt -X POST \
https://your-instance.platform.basepeak.ai/api/invoke/a17jlm7 \
-H "Authorization: Bearer sk-bpai-1-42-…" \
-H "Accept: application/json" \
--data 'What are our support SLAs?'

grep -i x-bpai-thread-id headers.txt
# x-bpai-thread-id: t1abcde

Continuing a thread

Invoke API

Either put the thread in the path, or send it as a header:

# Header form — works with an agent-scoped key.
curl -sS -X POST \
https://your-instance.platform.basepeak.ai/api/invoke/a17jlm7 \
-H "Authorization: Bearer sk-bpai-1-42-…" \
-H "X-BPAI-Thread-Id: t1abcde" \
-H "Accept: application/json" \
--data 'And what about weekends?'

# Path form — equivalent.
curl -sS -X POST \
https://your-instance.platform.basepeak.ai/api/invoke/a17jlm7/threads/t1abcde \
-H "Authorization: Bearer sk-bpai-1-42-…" \
-H "Accept: application/json" \
--data 'And what about weekends?'

If both are present, the path wins.

Keep the agent in the path

/api/invoke/{id} also accepts a thread ID as {id}, but the scope check is built from whatever is in the path — so /api/invoke/t1abcde demands agent:chat:t1abcde and a normal agent-scoped key gets a 403. Keep the agent in the path and pass the thread by header or as the {thread} segment.

OpenAI-compatible API

Send X-BPAI-Thread-Id alongside X-BPAI-Agent:

from openai import OpenAI

client = OpenAI(api_key=TOKEN, base_url=f"{BASE}/v1",
default_headers={"X-BPAI-Agent": "a17jlm7"})

# Turn 1 — with_raw_response so we can read the response header.
raw = client.chat.completions.with_raw_response.create(
model="ignored",
messages=[{"role": "user", "content": "What's the capital of Germany?"}],
)
thread = raw.headers.get("x-bpai-thread-id")
print(raw.parse().choices[0].message.content)

# Turn 2 — same thread, so "and of France?" resolves correctly.
client2 = OpenAI(api_key=TOKEN, base_url=f"{BASE}/v1",
default_headers={"X-BPAI-Agent": "a17jlm7",
"X-BPAI-Thread-Id": thread})
print(client2.chat.completions.create(
model="ignored",
messages=[{"role": "user", "content": "And of France?"}],
).choices[0].message.content)

Because the header is per-client in the OpenAI SDKs, a small helper is usually nicer than a second client — see below.

Running several conversations at once

Threads are independent. A single key scoped to an agent can hold one thread per end user, per ticket, per channel — whatever your unit of conversation is. There is no per-key thread limit and no need to serialise calls.

import httpx

class Conversation:
"""One BasePeak.AI thread. Create one per user/ticket/channel."""

def __init__(self, client: httpx.Client, agent: str, thread: str | None = None):
self._c, self._agent, self.thread = client, agent, thread

def ask(self, message: str) -> str:
headers = {"Accept": "application/json", "Content-Type": "text/plain"}
if self.thread:
headers["X-BPAI-Thread-Id"] = self.thread

r = self._c.post(f"/api/invoke/{self._agent}",
headers=headers, content=message, timeout=None)
r.raise_for_status()

# Capture the ID on the first turn; it is stable afterwards.
self.thread = r.headers["x-bpai-thread-id"]
return "".join(e.get("content", "") for e in r.json()["items"])


http = httpx.Client(base_url=BASE,
headers={"Authorization": f"Bearer {TOKEN}"})

alice = Conversation(http, "a17jlm7")
bob = Conversation(http, "a17jlm7")

alice.ask("My order 1234 hasn't arrived.")
bob.ask("How do I reset my password?")

# Each remembers only its own context.
print(alice.ask("What was my order number again?")) # → 1234
print(bob.thread, alice.thread) # two distinct IDs

Resuming later is just persisting conversation.thread next to your own user record and passing it back into the constructor.

Listing threads and replaying history

With a conversation-read scope, conversations are addressable over the API — a lost thread ID is no longer a lost conversation:

RouteScopeWhat it returns
GET /api/threadsthread:read | project:threadsThe key account's own conversations, filtered to what its scopes cover
GET /api/threads/{id}thread:read | project:threadsA single conversation with its state and last run
GET /api/threads/{id}/eventsthread:read | project:threadsThe history — the same events the streaming call emits; ?runID=… selects a single run
POST /api/threads/{id}/abortagent:chatCancels the in-flight run

Two ways to grant conversation reads

The three read routes accept either scope, and a key holding both sees the union of what each covers:

ScopeCoversReach for it when
thread:read:<agent>Every conversation of that agent, in every project it backs and outside any projectThe integration is the agent's operator and needs its whole history
project:threads:<project>Only the conversations of that one projectThe integration works inside a project — the usual case

project:threads is the narrower of the two whenever an agent backs more than one project, and it is what pairs with project:chat: a key holding only project:chat:<p> can converse but cannot read anything back, so it has to capture the X-Bpai-Thread-Id response header and store the ID itself. Adding project:threads:<p> closes that gap without granting anything outside the project. Neither scope implies the other, and project:chat implies neither.

POST /api/threads/{id}/abort is deliberately outside this: cancelling a run is the same authority as starting one, so it stays on the chat scope. A read-only key cannot stop a run.

One limit holds regardless of scopes: a key never sees conversation data its owning account cannot. A wildcard thread:read:* or project:threads:* widens the scopes, not the permission — another account's conversations stay out of reach.

Three details worth knowing:

  • The project container is not addressable through these thread routes; conversations inside it are. The project itself — the parent thread — cannot be reached through GET /api/threads & co. A conversation inside a project can, as long as the key account owns it: it appears in GET /api/threads and reads back through GET /api/threads/{id}. That holds for project:threads:<p> too — it lists the project's conversations, never the container thread, whose ID GET /api/threads/{id} would refuse anyway. Address the project itself through POST /api/projects/{project_id}/invoke and its companion routes — see Projects.
  • GET /api/threads is stricter than the {id} routes. The list shows only conversations the key account owns. The three {id} routes additionally reach conversations shared with the account — directly, through one of its groups, or through a share granted to every signed-in account. Those never appear in the list, but they read back if you know the ID.
  • Task runs do not appear in the list. Every scheduled, webhook-triggered or email-triggered task run does create its own conversation, but GET /api/threads returns chat conversations only — a frequently running task would otherwise bury the list completely. The runs live under GET /api/tasks/{id}/runs (scope task:manage), and a single run's conversation still reads back through GET /api/threads/{id} if you know the ID — with thread:read only. project:threads covers a project's conversations and not its runs, so it neither lists nor replays them; use project:tasks:<p> for a project's task runs.
# List your own conversations (first page).
curl -sS -H "Authorization: Bearer sk-bpai-1-42-…" \
-H "Accept: application/json" \
https://your-instance.platform.basepeak.ai/api/threads
{
"items": [
{ "id": "t1abcde", "assistantID": "a17jlm7", "state": "continue" }
],
"nextCursor": "MTcwMDAwMDAwMHx0MWFiY2Rl"
}

Paging

The response is limited to one page — on an account with a long history a complete list would be neither usable nor transferable:

ParameterMeaning
limitItems per page. Default 50, maximum 200; larger values are clamped to 200.
afterCursor from the previous page's nextCursor field. Treat it as an opaque string.

Ordering is by creation time, newest first. nextCursor is present only while further pages follow — when the field is absent, that was the last one. An invalid limit or after is rejected with 400, so a typo cannot silently hand you page one again.

threads, cursor = [], None
while True:
params = {"limit": 100, **({"after": cursor} if cursor else {})}
page = api.get("/api/threads", params=params).json()
threads += page["items"]
cursor = page.get("nextCursor")
if not cursor:
break

Polling an async run

?async=true returns threadID and runID right away. Keep both: the runID names the exact run you started, and it is what makes collecting the result unambiguous.

Without runID the call targets the wrong run

GET /api/threads/{id}/events with no runID anchors on the thread's last completed run — not the one you just started. How that plays out depends on the thread, and neither outcome is what you want:

  • Resumed thread: you get the previous turn's answer, delivered with runComplete: true and no hint that it is stale.
  • First turn of a new thread: there is no completed run yet, so the call anchors on the running one instead — and stays open until it finishes.

Always pass ?runID=….

There are three ways to collect the result.

1. Wait for it (one request, connection held open). With ?runID=… and Accept: application/json the call blocks until that run finishes, then answers with exactly its events as a single JSON document ({"items": [...]}). There is nothing to retry and nothing to sleep between — a sleep loop around this would never reach a second iteration. The cost is an HTTP connection held open for the whole run, which is the very timeout risk ?async=true exists to avoid.

curl -sS -H "Authorization: Bearer sk-bpai-1-42-…" \
-H "Accept: application/json" \
'https://your-instance.platform.basepeak.ai/api/threads/t1abcde/events?runID=r1xyz'

2. Stream it. The same call with Accept: text/event-stream delivers events as they are produced, with keepalive pings through longer quiet stretches.

3. Poll without holding a connection. GET /api/threads/{id} answers immediately and carries the run state: the run is complete exactly when lastRunID matches your runID. This is the option for runs that outlast an HTTP timeout.

import httpx, time

http = httpx.Client(base_url=BASE, headers={"Authorization": f"Bearer {TOKEN}"})
as_json = {"Accept": "application/json"}

start = http.post("/api/invoke/a17jlm7?async=true",
content="Build the monthly report.").json()
thread, run = start["threadID"], start["runID"]

# Short requests, arbitrarily long run: this call does not block.
while True:
status = http.get(f"/api/threads/{thread}", headers=as_json).json()
if status.get("lastRunID") == run:
break
time.sleep(2)

# The run is complete, so this usually returns at once — and it returns that run,
# not the history before it.
events = http.get(f"/api/threads/{thread}/events",
params={"runID": run}, headers=as_json).json()["items"]

# Success or failure is decided by this run's own events, not by `state`.
failure = next((e["error"] for e in events if e.get("error")), None)
if failure:
raise RuntimeError(f"the run ended with an error: {failure}")

print("".join(e.get("content", "") for e in events))

# Cancel if the run takes too long:
# http.post(f"/api/threads/{thread}/abort")

Check lastRunID rather than the currentRunID sitting next to it: both flip at the same moment, but currentRunID is also empty for the brief window before a freshly started run is recorded there, whereas lastRunID == runID names your run unambiguously.

state is an indication of where the thread stands, not a reliable outcome for the run. Do not expect finished there: an agent turn normally ends as continue, because the conversation can be carried on. The values you can see are continue (the normal case), finished, waiting and error.

state describes the thread, not your run

state reports the final state of the most recent run on the thread. If a run pauses for an external step along the way — a login that has to be confirmed in the UI, say, or a hand-off to another agent — it reads waiting until the run resumes, and shows its real outcome afterwards. A later run on the same thread then overwrites the value with its own.

For the outcome of a specific run, its own events under GET /api/threads/{id}/events?runID=… are what count. A failed run emits an event there with the error field set, which is exactly what the sample above checks.

A run that genuinely is still waiting for a confirmation holds that call open until it arrives — so set a timeout.

One exception: on a thread belonging to a task run, state reports the task's state instead of the run's — Pending, Running, Complete, Error, Blocked or Exhausted, capitalised and outside the set listed above. GET /api/threads does not list those threads; they still read back through GET /api/threads/{id} if you know the ID.

Persisting the thread ID on the first turn is still the most convenient path: GET /api/threads returns every conversation on the account, not just the ones a given integration created.

How API threads appear in the UI

Threads created by an API key show up for their owner with two quirks:

  • Name is derived from the first user message (truncated near 60 characters on a word boundary) only for threads created through /v1/chat/completions. Threads created through /api/invoke/{id} are left unnamed.

  • Project shows the literal "API" in the admin thread list, because the key carries an agent scope rather than a project scope. These threads are deliberately not attached to a project.

    This applies to /api/invoke/{id} and /v1/chat/completions. A thread started through POST /api/projects/{project_id}/invoke is the exception: it belongs to the project and appears there like any other conversation — see Projects.