Ga naar inhoud

Events volgen met een stream of webhook

Core kan je applicatie laten weten wanneer er iets verandert in je organisatie: een thread kreeg een item, een run is klaar, een document is verwerkt. Je kunt een stream openhouden, of Core een webhook laten aanroepen. Beide bevatten verwijzingen, nooit inhoud: id's en een status. Haal het ding zelf op via de API als je het nodig hebt.

Je sleutel heeft hiervoor geen capability nodig. Elke sleutel leest de events van zijn eigen organisatie.

Hoe een event eruitziet

{
  "id": "evt_01M3…",
  "type": "response.completed",
  "cursor": "4418",
  "created_at": "2026-09-25T09:14:03Z",
  "refs": {"response_id": "resp_01M3…", "status": "completed"}
}
type refs
thread.item.created thread_id, item_id, item_type, role, en response_id als een beurt het schreef
response.completed, response.failed, response.incomplete, response.cancelled response_id, status
response.check_failed response_id, en de name, stage, kind en outcome (failed of errored) van de check: een live check slaagde niet, nooit met de tekst die hij las
run.suspended, run.resumed response_id, status
run.approval.requested, run.approval.decided response_id, approval_id, call_id, tool, en status (pending) of decision (approved, rejected): een run wacht op goedkeuring van een toolaanroep, en daarover is beslist
thread.task.started, thread.task.completed, thread.task.failed, thread.task.cancelled, thread.task.expired thread_id, task_id, tool, status, en response_id: de beurt die de taak startte
knowledge.source.created, knowledge.source.updated, knowledge.source.deleted, knowledge.source.restored source_id, collection_id
knowledge.source.ingested source_id, index_id, status, en error_code als het mislukte
knowledge.index.reindex_requested, knowledge.index.reindexed index_id, run_id, en status als het klaar is
knowledge.connector.synced connector_id, run_id, en de aantallen added, updated, removed, skipped, failed
memory.commitment_overdue memory_id, index_id, status (overdue): een open toezegging in het geheugen passeerde haar vervaltijd, één keer per vervaltijd
mail.received mail_connector, folder, uid: een nieuw bericht in een map die een mailkoppeling bewaakt, nooit de afzender, het onderwerp of de tekst (Nieuwe mail)
datastore.record.written, datastore.record.deleted datastore_id, datastore, key, revision, en created bij een schrijfactie: een record van een datastore waarvoor emit_events aanstaat, nooit de gegevens zelf
workflow.run.started, workflow.run.completed, workflow.run.failed, workflow.run.cancelled, workflow.run.expired workflow_id, run_id, status, en error_code als hij mislukte: een workflowrun
workflow.decision.requested, workflow.decision.decided, workflow.decision.expired workflow_id, run_id, step_id, node_id, assignee_kind, status, en outcome zodra er een genomen is: een beslissing
improvements.proposal.created proposal_id, check, target_kind, target_id, status: een nieuw voorstel
improvements.analysis.finished analysis_id, status, proposals: een analyse is klaar
usage.threshold_reached cap_kind (organisation of key), key_id bij de limiet van een sleutel, threshold, period_start: het verbruik van deze maand bereikte een van de budgetgrenzen van een maandlimiet, één keer per grens, limiet en maand, ook als de mail erover uitstaat; nooit de limiet of het verbruikte bedrag
usage.cap_exceeded subject (key:<id>, tenant:<id> of ancestor): een maandlimiet weigerde een aanroep, één keer per minuut (het weigeringsvenster van de omgeving) voor dezelfde weigering; nooit de limiet of het verbruikte bedrag, die GET /usage/caps geeft

Een veld dat een event niet heeft, ontbreekt.

Elke soort kan een stream en een webhook bereiken. Een workflowtrigger luistert ook naar de meeste, behalve de soorten die elke agentstap zelf maakt: een soort response.*, run.* of thread.*, response.check_failed inbegrepen, kan geen workflow starten.

De events knowledge.source.created, .updated, .deleted en .restored worden alleen voor kennisdocumenten gestuurd, via welke route ze ook zijn gemaakt, gewijzigd, verwijderd of teruggezet: een text-, records- of connector-bron, en een bestand dat in een kenniscollectie is geüpload of gekopieerd (Wat een bestands-id bereikt). Een verwijderd document gaat eerst naar de prullenbak; .restored zegt dat het terug is, zodat een abonnee die het bij .deleted weghaalde, het kan terugzetten. Een bestand in de eigen collectie van een sleutel of in de collectie uploads van je organisatie, en een artefact, sturen geen van de vier. Wat daarmee gebeurde, staat in de auditgebeurtenissen van je sleutel, onder namen als file.created, file.deleted, file.restored en artifact.deleted.

Een stream openhouden

curl -N "$CORE_URL/events/stream" -H "Authorization: Bearer $CORE_KEY"

Het antwoord is server-sent events. De id van elk frame is de cursor van het event, event is het type en data is het event hierboven. Een regel die met : begint houdt de verbinding open; negeer die.

Core beëindigt de stream na ongeveer tien minuten. Maak opnieuw verbinding met de laatste cursor die je las, in de header Last-Event-ID of als ?after=, en je mist niets. Zonder cursor begint de stream bij nu. Events blijven drie dagen beschikbaar. Is je cursor ouder dan dat, dan stuurt de stream eerst één gap-frame (event: gap); lees dan de toestand die je bijhield opnieuw in.

Core beëindigt de stream ook zodra je sleutel of token niet meer geldig is. Je sleutels en je console kunnen hooguit tien streams tegelijk openhouden, en de subject-tokens van elke persoon nog drie van hun eigen; nog een geeft 429 met een Retry-After-header.

In een browser gebruik je fetch: EventSource kan de header Authorization niet meesturen, en Core neemt de credential op geen andere manier aan. Geef het apparaat een subject-token, nooit je sleutel. De stream van een subject-token bevat de events over de eigen threads, antwoorden en bestanden van die persoon, en -- waar je deployment groepsgesprekken op basis van regels aanzet (threads_group_reach_by_rule, standaard uit) -- de events over een gesprek dat een regel die de persoon noemt voor haar opent: in een gesprek waarin meerdere mensen schrijven hoort het apparaat van elke persoon de berichten en antwoorden van de anderen binnenkomen. De events van een gedeeld gesprek waarop geen regel de persoon noemt, bevat ze nooit.

const response = await fetch(`${coreUrl}/events/stream`, {
  headers: { Authorization: `Bearer ${subjectToken}`, "Last-Event-ID": lastCursor ?? "" },
});
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
for (let buffer = ""; ; ) {
  const { value, done } = await reader.read();
  if (done) break; // Core beëindigde de stream: verbind opnieuw met lastCursor
  buffer += value;
  const frames = buffer.split("\n\n");
  buffer = frames.pop();
  for (const frame of frames) {
    const data = frame.split("\n").find((line) => line.startsWith("data: "));
    if (!data || frame.startsWith("event: gap")) continue; // heartbeat, retry-hint of gap
    const event = JSON.parse(data.slice(6));
    lastCursor = event.cursor;
    redraw(event);
  }
}

Laat Last-Event-ID weg bij de eerste verbinding.

Een webhook ontvangen

curl -s -X POST "$CORE_URL/events/subscriptions" \
    -H "Authorization: Bearer $CORE_KEY" -H "Content-Type: application/json" \
    -d '{"url": "https://jouw-app.example/core-events",
         "kinds": ["response.completed", "response.failed"]}'

Core geeft 201 terug met het abonnement en zijn secret. Bewaar het geheim meteen: Core toont het niet nog eens. Laat kinds weg om elk type te ontvangen.

De host van de webhook moet een http- of https-host zijn die bij de toegestane hosts van je organisatie staat; regels over hosts in de policy van je sleutel gelden er ook voor. Core levert nooit af op een adres op een privénetwerk. Een sleutel met een regiobeperking bereikt alleen een host die de policy in een van die regio's plaatst. Core controleert de host nu en opnieuw voor elke aflevering. Een host die wordt geweigerd geeft 403 en maakt niets aan. Een organisatie heeft hooguit 20 abonnementen; nog een geeft 409.

Elke aflevering is een POST van één event met deze headers:

Header Waarvoor
Artific-Signature t=<unix-seconden>,v1=<handtekening>: bewijst dat Core het stuurde
Idempotency-Key gelijk bij elke herhaling van één aflevering: gooi een herhaling weg
Artific-Event-Id het id van het event

Antwoord met een 2xx. Bij iets anders, of geen antwoord, probeert Core het opnieuw met steeds langere pauzes. Afleveringen kunnen in een andere volgorde aankomen; sorteer op cursor.

De handtekening controleren

De handtekening is de HMAC-SHA256 van het tijdstip, een punt en precies de body die je ontving, met het geheim als sleutel, in hex. Controleer hem voordat je de body vertrouwt:

import hashlib, hmac, time

def verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
    try:
        parts = dict(part.split("=", 1) for part in header.split(","))
        sent, signature = parts["t"], parts["v1"]
        age = abs(time.time() - int(sent))
    except (KeyError, ValueError):
        return False  # no signature, or not one Core wrote
    expected = hmac.new(secret.encode(), f"{sent}.".encode() + body, hashlib.sha256)
    return age <= tolerance and hmac.compare_digest(expected.hexdigest(), signature)

Gebruik de ruwe bytes van de body, niet een opnieuw gecodeerde kopie van de geparste JSON.

Een callback van een run wordt op dezelfde manier ondertekend, met het callbackgeheim van de sleutel die de run startte, dus deze functie controleert beide.

Het geheim vervangen

curl -s -X POST "$CORE_URL/events/subscriptions/evs_01M3…/rotate-secret" \
    -H "Authorization: Bearer $CORE_KEY"

Het antwoord bevat het nieuwe geheim, en dat geldt meteen: zet het in je ontvanger zodra je het hebt. Een aflevering die al onderweg is kan nog de oude handtekening dragen; beantwoord die met iets anders dan 2xx en Core probeert het opnieuw, ondertekend met het nieuwe geheim.

GET /events/subscriptions toont je abonnementen en DELETE /events/subscriptions/{id} verwijdert er een. Geen van beide toont een geheim. Alleen de sleutel die een abonnement aanmaakte, of een sleutel met management, kan het geheim vervangen of het abonnement verwijderen. Verwijder je een sleutel, dan verdwijnen ook zijn abonnementen.