Skip to content

Follow events with a stream or a webhook

Core can tell your application when something changes in your organisation: a thread gained an item, a run finished, a document was ingested. You can hold a stream open, or have Core call a webhook. Both carry references, never content: ids and a status. Fetch the thing itself through the API when you need it.

Your key needs no capability for this. Every key reads its own organisation's events.

What an event looks like

{
  "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, and response_id when a turn wrote it
response.completed, response.failed, response.incomplete, response.cancelled response_id, status
response.check_failed response_id, and the check's name, stage, kind and outcome (failed or errored): a live check did not pass, never the text it read
run.suspended, run.resumed response_id, status
run.approval.requested, run.approval.decided response_id, approval_id, call_id, tool, and status (pending) or decision (approved, rejected): a run waits for approval of a tool call, and it was decided
thread.task.started, thread.task.completed, thread.task.failed, thread.task.cancelled, thread.task.expired thread_id, task_id, tool, status, and response_id: the turn that started the task
knowledge.source.created, knowledge.source.updated, knowledge.source.deleted, knowledge.source.restored source_id, collection_id
knowledge.source.ingested source_id, index_id, status, and error_code when it failed
knowledge.index.reindex_requested, knowledge.index.reindexed index_id, run_id, and status when it finished
knowledge.connector.synced connector_id, run_id, and the counts added, updated, removed, skipped, failed
memory.commitment_overdue memory_id, index_id, status (overdue): an open commitment in memory passed its due time, once per due time
mail.received mail_connector, folder, uid: a new message in a folder a mail connector watches, never its sender, subject or text (New mail)
datastore.record.written, datastore.record.deleted datastore_id, datastore, key, revision, and created on a write: a record of a datastore with emit_events on, never its data
workflow.run.started, workflow.run.completed, workflow.run.failed, workflow.run.cancelled, workflow.run.expired workflow_id, run_id, status, and error_code when it failed: a workflow run
workflow.decision.requested, workflow.decision.decided, workflow.decision.expired workflow_id, run_id, step_id, node_id, assignee_kind, status, and outcome once one was taken: a decision
improvements.proposal.created proposal_id, check, target_kind, target_id, status: a new proposal
improvements.analysis.finished analysis_id, status, proposals: an analysis finished
usage.threshold_reached cap_kind (organisation or key), key_id for a key's cap, threshold, period_start: this month's spend reached one of the budget marks of a monthly cap, once per mark, cap and month, also when its mail is turned off; never the cap or the amount spent
usage.cap_exceeded subject (key:<id>, tenant:<id> or ancestor): a monthly cap refused a call, once per minute (the deployment's refusal window) for the same refusal; never the cap or the amount spent, which GET /usage/caps answers

A field an event does not carry is left out.

Every kind can reach a stream and a webhook. A workflow trigger listens to most of them too, except the kinds every agent step produces itself: a response.*, run.* or thread.* kind, response.check_failed included, cannot start a workflow.

The knowledge.source.created, .updated, .deleted and .restored events are sent for knowledge documents only, whichever route created, changed, deleted or restored them: a text, records or connector source, and a file uploaded or copied into a knowledge collection (What a file id reaches). A deleted document goes to the trash first; .restored says it is back, so a subscriber that removed it on .deleted can put it back. A file in a key's own collection or in your organisation's uploads collection, and an artifact, send none of the four. What happened to those is in your key's audit events, under names such as file.created, file.deleted, file.restored and artifact.deleted.

Hold a stream open

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

The answer is server-sent events. Each frame's id is the event's cursor, its event the type, and its data the event above. A line that starts with : keeps the connection alive; ignore it.

Core ends the stream after about ten minutes. Reconnect with the last cursor you read, in the Last-Event-ID header or as ?after=, and you miss nothing. Without a cursor the stream starts from now. Events stay available for three days. If your cursor is older than that, the stream first sends one gap frame (event: gap); re-read the state you were keeping current.

Core also ends the stream when your key or token stops being valid. Your keys and your console can hold at most ten streams open at once, and each person's subject tokens three more of their own; one more returns 429 with a Retry-After header.

In a browser, use fetch: EventSource cannot send the Authorization header, and Core takes the credential no other way. Give the device a subject token, never your key. A subject token's stream carries the events about that person's own threads, responses and files, and -- where your deployment switches rule-based group threads on (threads_group_reach_by_rule, off by default) -- the events about a thread a rule that names the person opens to them: in a thread several people write in, each person's device hears the others' messages and answers arrive. It never carries the events of a shared thread no rule names the person on.

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 ended the stream: reconnect with 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 or gap
    const event = JSON.parse(data.slice(6));
    lastCursor = event.cursor;
    redraw(event);
  }
}

Leave out Last-Event-ID on the first connection.

Receive a webhook

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

Core returns 201 with the subscription and its secret. Store the secret now: Core does not show it again. Leave out kinds to receive every type.

The webhook host must be an http or https host on your organisation's allowed hosts; rules about hosts in your key's policy apply to it too. Core never delivers to an address on a private network. A key with a region restriction only reaches a host its policy places in one of those regions. Core checks the host now and again before every delivery. A host that is refused returns 403 and creates nothing. An organisation holds at most 20 subscriptions; one more returns 409.

Each delivery is a POST of one event with these headers:

Header What it is for
Artific-Signature t=<unix seconds>,v1=<signature>: proves Core sent it
Idempotency-Key the same on every retry of one delivery: drop a repeat
Artific-Event-Id the event's id

Answer with any 2xx. Anything else, or no answer, and Core retries with increasing delays. Deliveries can arrive out of order; order by cursor.

Verify the signature

The signature is the HMAC-SHA256 of the timestamp, a dot and the exact body you received, under the secret, as hex. Check it before you trust the body:

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)

Use the raw body bytes, not a re-encoded copy of the parsed JSON.

A run callback is signed the same way, under the callback secret of the key that started the run, so this function verifies both.

Rotate the secret

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

The answer carries the new secret, and it takes effect at once: put it into your receiver as soon as you have it. A delivery already on its way may still carry the old signature; answer it with a non-2xx and Core retries it, signed with the new secret.

GET /events/subscriptions lists your subscriptions and DELETE /events/subscriptions/{id} removes one. Neither shows a secret. Only the key that created a subscription, or a key with management, can rotate its secret or delete it. Deleting a key also removes its subscriptions.