Threads over the API
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 API | BasePeak.AI | |
|---|---|---|
| Where history lives | In your request — you resend every turn | On the server, in the thread |
| What you send | The full messages array | Just the new message |
| What identifies the conversation | nothing; it is stateless | the 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.
/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:
| Route | Scope | What it returns |
|---|---|---|
GET /api/threads | thread:read | project:threads | The key account's own conversations, filtered to what its scopes cover |
GET /api/threads/{id} | thread:read | project:threads | A single conversation with its state and last run |
GET /api/threads/{id}/events | thread:read | project:threads | The history — the same events the streaming call emits; ?runID=… selects a single run |
POST /api/threads/{id}/abort | agent:chat | Cancels 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:
| Scope | Covers | Reach for it when |
|---|---|---|
thread:read:<agent> | Every conversation of that agent, in every project it backs and outside any project | The integration is the agent's operator and needs its whole history |
project:threads:<project> | Only the conversations of that one project | The 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 inGET /api/threadsand reads back throughGET /api/threads/{id}. That holds forproject:threads:<p>too — it lists the project's conversations, never the container thread, whose IDGET /api/threads/{id}would refuse anyway. Address the project itself throughPOST /api/projects/{project_id}/invokeand its companion routes — see Projects. GET /api/threadsis 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/threadsreturns chat conversations only — a frequently running task would otherwise bury the list completely. The runs live underGET /api/tasks/{id}/runs(scopetask:manage), and a single run's conversation still reads back throughGET /api/threads/{id}if you know the ID — withthread:readonly.project:threadscovers a project's conversations and not its runs, so it neither lists nor replays them; useproject: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:
| Parameter | Meaning |
|---|---|
limit | Items per page. Default 50, maximum 200; larger values are clamped to 200. |
after | Cursor 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.
runID the call targets the wrong runGET /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: trueand 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 runstate 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 throughPOST /api/projects/{project_id}/invokeis the exception: it belongs to the project and appears there like any other conversation — see Projects.
Related docs
- Invoke API — streaming and async
- OpenAI-compatible API —
X-BPAI-Thread-Idon/v1 - Files — documents in a conversation
- Projects — addressing the project itself