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.