Threads über die API
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-API | BasePeak.AI | |
|---|---|---|
| Wo der Verlauf liegt | In Ihrer Anfrage – Sie senden jeden Zug erneut | Serverseitig im Thread |
| Was Sie senden | Das vollständige messages-Array | Nur die neue Nachricht |
| Was die Unterhaltung identifiziert | nichts, sie ist zustandslos | die 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.
/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:
| Route | Scope | Was sie liefert |
|---|---|---|
GET /api/threads | thread:read | project:threads | Die eigenen Unterhaltungen des Schlüsselkontos, gefiltert auf die Reichweite der Scopes |
GET /api/threads/{id} | thread:read | project:threads | Eine einzelne Unterhaltung mit Status und letztem Lauf |
GET /api/threads/{id}/events | thread:read | project:threads | Den Verlauf – dieselben Ereignisse wie beim Streaming-Aufruf; ?runID=… wählt einen einzelnen Lauf |
POST /api/threads/{id}/abort | agent:chat | Bricht 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:
| Scope | Reicht über | Wann geeignet |
|---|---|---|
thread:read:<agent> | Jede Unterhaltung dieses Agenten – in allen Projekten, die er bedient, und außerhalb jedes Projekts | Die Integration betreibt den Agenten und braucht dessen gesamten Verlauf |
project:threads:<projekt> | Nur die Unterhaltungen genau dieses Projekts | Die 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 inGET /api/threadsund ist unterGET /api/threads/{id}lesbar. Das gilt auch fürproject:threads:<p>– der Scope listet die Unterhaltungen des Projekts, nie den Container-Thread, dessen IDGET /api/threads/{id}ohnehin ablehnen würde. Das Projekt selbst adressieren Sie überPOST /api/projects/{project_id}/invokeund die dazugehörigen Routen – siehe Projekte. GET /api/threadsist 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/threadsliefert ausschließlich Chat-Unterhaltungen – eine häufig laufende Aufgabe würde die Liste sonst vollständig überdecken. Die Läufe stehen unterGET /api/tasks/{id}/runs(Scopetask:manage); die Unterhaltung eines einzelnen Laufs bleibt mit bekannter ID überGET /api/threads/{id}lesbar – allein mitthread:read.project:threadsdeckt 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 istproject: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:
| Parameter | Bedeutung |
|---|---|
limit | Einträge pro Seite. Vorgabe 50, Maximum 200; größere Werte werden auf 200 begrenzt. |
after | Cursor 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.
runID trifft der Aufruf den falschen LaufGET /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: trueund 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 Laufstate 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/completionsentstanden 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 überPOST /api/projects/{project_id}/invokegestarteter Thread ist die Ausnahme: Er hängt am Projekt und erscheint dort wie jede andere Unterhaltung – siehe Projekte.
Weiterführende Dokumentation
- Invoke-API – Streaming und async
- OpenAI-kompatible API –
X-BPAI-Thread-Idauf/v1 - Dateien – Dokumente in einer Unterhaltung
- Projekte – das Projekt selbst adressieren