Zum Hauptinhalt springen

Threads über die API

Premium-Funktion

Ein Thread ist eine einzelne Unterhaltung mit einem Agenten. Er hält den Nachrichtenverlauf, sodass Sie frühere Züge nicht erneut senden – das ist der wesentliche Unterschied zwischen der BasePeak.AI-API und einer zustandslosen Chat-API.

Ein einzelner API-Schlüssel kann beliebig viele Threads bedienen, parallel und unabhängig voneinander.

Wie der Zustand hier funktioniert

OpenAI-APIBasePeak.AI
Wo der Verlauf liegtIn Ihrer Anfrage – Sie senden jeden Zug erneutServerseitig im Thread
Was Sie sendenDas vollständige messages-ArrayNur die neue Nachricht
Was die Unterhaltung identifiziertnichts, sie ist zustandslosdie Thread-ID

Bei /v1/chat/completions lohnt es, die Konsequenz deutlich zu sagen: Der Verlauf wird nicht aus Ihrem messages-Array rekonstruiert. Nur die letzte Nutzernachricht wird als Eingabe verwendet. Zehn frühere Züge mitzusenden verschafft dem Agenten keine zehn Züge Kontext – die Thread-ID zu übergeben schon.

Einen Thread beginnen

Lassen Sie jede Thread-Angabe weg, dann wird einer für Sie angelegt. Beide Schnittstellen liefern die neue ID im Antwort-Header X-BPAI-Thread-Id.

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 'Wie lauten unsere Support-SLAs?'

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

Einen Thread fortsetzen

Invoke-API

Setzen Sie den Thread entweder in den Pfad oder senden Sie ihn als Header:

# Header-Variante – funktioniert mit einem agentenbezogenen Schlüssel.
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 'Und was gilt an Wochenenden?'

# Pfad-Variante – gleichwertig.
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 'Und was gilt an Wochenenden?'

Sind beide vorhanden, gewinnt der Pfad.

Lassen Sie den Agenten im Pfad

/api/invoke/{id} akzeptiert als {id} auch eine Thread-ID, doch die Scope-Prüfung wird aus dem gebildet, was im Pfad steht – /api/invoke/t1abcde verlangt also agent:chat:t1abcde, und ein normaler agentenbezogener Schlüssel erhält 403. Lassen Sie den Agenten im Pfad und übergeben Sie den Thread per Header oder als Segment {thread}.

OpenAI-kompatible API

Senden Sie X-BPAI-Thread-Id zusammen mit X-BPAI-Agent:

from openai import OpenAI

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

# Zug 1 – with_raw_response, um den Antwort-Header lesen zu können.
raw = client.chat.completions.with_raw_response.create(
model="ignored",
messages=[{"role": "user", "content": "Was ist die Hauptstadt von Deutschland?"}],
)
thread = raw.headers.get("x-bpai-thread-id")
print(raw.parse().choices[0].message.content)

# Zug 2 – gleicher Thread, damit „und die von Frankreich?“ korrekt aufgelöst wird.
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": "Und die von Frankreich?"}],
).choices[0].message.content)

Da der Header in den OpenAI-SDKs pro Client gilt, ist ein kleiner Helfer meist angenehmer als ein zweiter Client – siehe unten.

Mehrere Unterhaltungen gleichzeitig

Threads sind unabhängig. Ein einzelner Schlüssel mit Agenten-Scope kann einen Thread pro Person, pro Ticket oder pro Kanal halten – was auch immer Ihre Einheit einer Unterhaltung ist. Es gibt kein Thread-Limit pro Schlüssel und keinen Grund, Aufrufe zu serialisieren.

import httpx

class Conversation:
"""Ein BasePeak.AI-Thread. Legen Sie einen pro Person/Ticket/Kanal an."""

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()

# Beim ersten Zug die ID übernehmen; danach bleibt sie stabil.
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("Meine Bestellung 1234 ist nicht angekommen.")
bob.ask("Wie setze ich mein Passwort zurück?")

# Jede erinnert sich nur an ihren eigenen Kontext.
print(alice.ask("Wie war meine Bestellnummer noch?")) # → 1234
print(bob.thread, alice.thread) # zwei verschiedene IDs

Später fortzusetzen bedeutet lediglich, conversation.thread neben Ihrem eigenen Nutzerdatensatz zu speichern und beim Konstruktor wieder zu übergeben.

Threads auflisten und Verlauf abrufen

Mit einem Lese-Scope für Unterhaltungen sind diese über die API adressierbar – eine verlorene Thread-ID ist damit kein Datenverlust mehr:

RouteScopeWas sie liefert
GET /api/threadsthread:read | project:threadsDie eigenen Unterhaltungen des Schlüsselkontos, gefiltert auf die Reichweite der Scopes
GET /api/threads/{id}thread:read | project:threadsEine einzelne Unterhaltung mit Status und letztem Lauf
GET /api/threads/{id}/eventsthread:read | project:threadsDen Verlauf – dieselben Ereignisse wie beim Streaming-Aufruf; ?runID=… wählt einen einzelnen Lauf
POST /api/threads/{id}/abortagent:chatBricht den laufenden Lauf ab

Zwei Wege, Leserechte auf Unterhaltungen zu vergeben

Die drei Leserouten akzeptieren einen von beiden Scopes; ein Schlüssel mit beiden sieht die Vereinigung beider Reichweiten:

ScopeReicht überWann geeignet
thread:read:<agent>Jede Unterhaltung dieses Agenten – in allen Projekten, die er bedient, und außerhalb jedes ProjektsDie Integration betreibt den Agenten und braucht dessen gesamten Verlauf
project:threads:<projekt>Nur die Unterhaltungen genau dieses ProjektsDie Integration arbeitet innerhalb eines Projekts – der Normalfall

project:threads ist der engere der beiden Scopes, sobald ein Agent mehr als ein Projekt bedient, und er ist das Gegenstück zu project:chat: Ein Schlüssel mit ausschließlich project:chat:<p> kann sprechen, aber nichts zurücklesen – er muss den Antwort-Header X-Bpai-Thread-Id mitschneiden und die ID selbst aufbewahren. project:threads:<p> schließt diese Lücke, ohne etwas außerhalb des Projekts freizugeben. Keiner der beiden Scopes impliziert den anderen, und project:chat impliziert keinen von beiden.

POST /api/threads/{id}/abort bleibt bewusst außen vor: Einen Lauf abzubrechen ist dieselbe Befugnis wie ihn zu starten, deshalb bleibt die Route beim Chat-Scope. Ein rein lesender Schlüssel kann keinen Lauf stoppen.

Eine Grenze gilt unabhängig von den Scopes: Ein Schlüssel sieht keine Unterhaltungsdaten, die dem zugehörigen Konto nicht zugänglich sind. Ein Wildcard-Scope thread:read:* oder project:threads:* erweitert die Scopes, nicht die Berechtigung – Unterhaltungen fremder Konten bleiben unerreichbar.

Drei Feinheiten dazu:

  • Der Projekt-Container ist über diese Thread-Routen nicht adressierbar, Unterhaltungen darin schon. Das Projekt selbst – der Eltern-Thread – ist über GET /api/threads & Co. nicht erreichbar. Eine Unterhaltung innerhalb eines Projekts dagegen schon, sofern das Schlüsselkonto sie besitzt: sie erscheint in GET /api/threads und ist unter GET /api/threads/{id} lesbar. Das gilt auch für project:threads:<p> – der Scope listet die Unterhaltungen des Projekts, nie den Container-Thread, dessen ID GET /api/threads/{id} ohnehin ablehnen würde. Das Projekt selbst adressieren Sie über POST /api/projects/{project_id}/invoke und die dazugehörigen Routen – siehe Projekte.
  • GET /api/threads ist strenger als die {id}-Routen. Die Liste zeigt ausschließlich Unterhaltungen, die dem Schlüsselkonto selbst gehören. Die drei {id}-Routen erreichen zusätzlich Unterhaltungen, die dem Konto freigegeben wurden – direkt, über eine seiner Gruppen oder über eine Freigabe an alle angemeldeten Konten. Diese erscheinen nicht in der Liste, sind mit bekannter ID aber lesbar.
  • Aufgabenläufe erscheinen nicht in der Liste. Jeder geplante, per Webhook oder per E-Mail ausgelöste Aufgabenlauf legt zwar eine eigene Unterhaltung an, doch GET /api/threads liefert ausschließlich Chat-Unterhaltungen – eine häufig laufende Aufgabe würde die Liste sonst vollständig überdecken. Die Läufe stehen unter GET /api/tasks/{id}/runs (Scope task:manage); die Unterhaltung eines einzelnen Laufs bleibt mit bekannter ID über GET /api/threads/{id} lesbar – allein mit thread:read. project:threads deckt die Unterhaltungen eines Projekts ab, nicht seine Läufe: Der Scope listet sie nicht und spielt sie nicht ab. Für die Aufgabenläufe eines Projekts ist project:tasks:<p> zuständig.
# Die eigenen Unterhaltungen auflisten (erste Seite).
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"
}

Seitenweise abrufen

Die Antwort ist auf eine Seite begrenzt – bei einem Konto mit langer Historie wäre eine vollständige Liste weder brauchbar noch übertragbar:

ParameterBedeutung
limitEinträge pro Seite. Vorgabe 50, Maximum 200; größere Werte werden auf 200 begrenzt.
afterCursor aus dem Feld nextCursor der vorherigen Seite. Behandeln Sie ihn als undurchsichtige Zeichenkette.

Sortiert wird nach Erstellungszeit, neueste zuerst. nextCursor erscheint nur, solange weitere Seiten folgen – fehlt das Feld, war es die letzte. Ein ungültiger limit- oder after-Wert wird mit 400 abgewiesen, damit ein Tippfehler nicht stillschweigend wieder Seite eins liefert.

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

Einen asynchronen Lauf abfragen

?async=true liefert sofort threadID und runID. Behalten Sie beide: die runID benennt genau den Lauf, den Sie gestartet haben, und macht das Abholen des Ergebnisses eindeutig.

Ohne runID trifft der Aufruf den falschen Lauf

GET /api/threads/{id}/events ohne runID richtet sich nach dem letzten abgeschlossenen Lauf des Threads – nicht nach dem, den Sie gerade gestartet haben. Das endet je nach Thread unterschiedlich, in beiden Fällen falsch:

  • Fortgesetzter Thread: Sie erhalten die Antwort des vorherigen Zugs, vollständig mit runComplete: true und ohne jeden Hinweis darauf, dass sie veraltet ist.
  • Erster Zug eines neuen Threads: Es gibt noch keinen abgeschlossenen Lauf, also richtet sich der Aufruf nach dem laufenden – und bleibt offen, bis dieser fertig ist.

Übergeben Sie immer ?runID=….

Zum Abholen gibt es drei Wege.

1. Warten (eine Anfrage, offene Verbindung). Mit ?runID=… und Accept: application/json blockiert der Aufruf, bis dieser Lauf fertig ist, und antwortet dann mit genau seinen Ereignissen als einem JSON-Dokument ({"items": [...]}). Es gibt hier nichts zu wiederholen und nichts zu schlafen – eine Schleife mit sleep würde nie einen zweiten Durchlauf erreichen. Der Preis ist eine für die gesamte Laufzeit offene HTTP-Verbindung, also genau das Timeout-Risiko, dem ?async=true ausweichen soll.

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. Streamen. Derselbe Aufruf mit Accept: text/event-stream liefert die Ereignisse, während sie entstehen – mit Keepalive-Pings in längeren stillen Phasen.

3. Pollen ohne offene Verbindung. GET /api/threads/{id} antwortet sofort und trägt den Laufzustand: der Lauf ist genau dann abgeschlossen, wenn lastRunID Ihrer runID entspricht. Das ist der Weg für Läufe, die länger dauern als ein HTTP-Timeout erlaubt.

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="Erstelle den Monatsbericht.").json()
thread, run = start["threadID"], start["runID"]

# Kurze Anfragen, beliebig lange Laufzeit: dieser Aufruf blockiert nicht.
while True:
status = http.get(f"/api/threads/{thread}", headers=as_json).json()
if status.get("lastRunID") == run:
break
time.sleep(2)

# Der Lauf ist abgeschlossen, dieser Aufruf antwortet also in der Regel sofort –
# und liefert genau diesen Lauf, nicht den Verlauf davor.
events = http.get(f"/api/threads/{thread}/events",
params={"runID": run}, headers=as_json).json()["items"]

# Über Erfolg oder Fehler entscheiden die Ereignisse dieses Laufs, nicht `state`.
failure = next((e["error"] for e in events if e.get("error")), None)
if failure:
raise RuntimeError(f"Der Lauf ist mit einem Fehler beendet worden: {failure}")

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

# Abbrechen, falls der Lauf zu lange dauert:
# http.post(f"/api/threads/{thread}/abort")

Prüfen Sie lastRunID und nicht das ebenfalls vorhandene currentRunID: beide wechseln im selben Moment, aber currentRunID ist auch in der kurzen Spanne leer, bevor ein neu gestarteter Lauf dort vermerkt ist – lastRunID == runID benennt dagegen unmissverständlich Ihren Lauf.

state ist ein Hinweis auf den Zustand des Threads, kein verlässliches Ergebnis des Laufs. Erwarten Sie dort nicht finished: ein Agentenzug endet im Normalfall mit continue, weil die Unterhaltung fortgeführt werden kann. Möglich sind continue (der Normalfall), finished, waiting und error.

state beschreibt den Thread, nicht Ihren Lauf

state gibt den Endzustand des zuletzt auf dem Thread gelaufenen Laufs wieder. Hält ein Lauf unterwegs für einen externen Schritt an – etwa eine Anmeldung, die in der Oberfläche bestätigt werden muss, oder die Übergabe an einen anderen Agenten –, steht dort waiting, bis er fortgesetzt ist; danach zeigt state sein tatsächliches Ergebnis. Startet anschließend ein weiterer Lauf auf demselben Thread, überschreibt dessen Ergebnis den Wert.

Für das Ergebnis eines bestimmten Laufs sind deshalb seine eigenen Ereignisse unter GET /api/threads/{id}/events?runID=… maßgeblich. Ein fehlgeschlagener Lauf liefert dort ein Ereignis mit gesetztem Feld error; genau das prüft das Beispiel oben.

Wartet ein Lauf wirklich noch auf eine Bestätigung, hält er diesen Aufruf offen, bis sie erfolgt – setzen Sie also ein Timeout.

Eine Ausnahme: Gehört der Thread zu einem Aufgabenlauf, meldet state den Zustand der Aufgabe statt den des Laufs – also Pending, Running, Complete, Error, Blocked oder Exhausted, groß geschrieben und außerhalb der oben genannten Werte. GET /api/threads listet solche Threads nicht auf; mit bekannter ID sind sie über GET /api/threads/{id} weiterhin lesbar.

Die Thread-ID beim ersten Zug zu speichern bleibt trotzdem der bequemste Weg: GET /api/threads liefert alle Unterhaltungen des Kontos, nicht nur die einer bestimmten Integration.

Wie API-Threads in der Oberfläche erscheinen

Von einem API-Schlüssel erzeugte Threads erscheinen im zugehörigen Konto mit zwei Besonderheiten:

  • Der Name wird aus der ersten Nutzernachricht abgeleitet (gekürzt bei etwa 60 Zeichen an einer Wortgrenze) – nur bei Threads, die über /v1/chat/completions entstanden sind. Über /api/invoke/{id} erzeugte Threads bleiben ohne Namen.

  • Als Projekt erscheint in der Admin-Thread-Liste wörtlich „API“, weil der Schlüssel einen Agenten-Scope und keinen Projekt-Scope trägt. Diese Threads sind absichtlich keinem Projekt zugeordnet.

    Das gilt für /api/invoke/{id} und /v1/chat/completions. Ein über POST /api/projects/{project_id}/invoke gestarteter Thread ist die Ausnahme: Er hängt am Projekt und erscheint dort wie jede andere Unterhaltung – siehe Projekte.

Weiterführende Dokumentation