Events expose run progress for polling, webhook consumers, and server-sent event streams.
Event Object
{
"id": "evt_01hxbexample",
"event_id": "evt_01hxbexample",
"sequence": 1714500014000001,
"event_type": "step.completed",
"occurred_at": 1714500014000,
"run_id": "550e8400-e29b-41d4-a716-446655440000",
"api_version": "2026-04-22",
"data": {
"step_index": 0,
"kind": "fetch",
"title": "Find climate-tech startups",
"status": "completed",
"items_out": 3,
"duration_ms": 1870
}
}id is an alias for event_id. Use either value as the event identifier.
sequence is an opaque integer that orders a run's events; every read of an event gives the same number. Page with next_cursor, not sequence. Do not parse it as a timestamp — its numeric range may change in future API versions. Use occurred_at (Unix epoch milliseconds) for time-based ordering.
Event Types
| Event type | Meaning |
|---|---|
run.created | Run was accepted. |
run.started | Run execution began. |
run.succeeded | Run completed successfully. |
run.failed | Run failed. |
run.paused | Run paused (transient; resumes automatically). |
run.canceled | Run was canceled. |
run.pre_validation | Synchronous understander validation completed (query inputs only). Attributed as step index -1. |
step.started | A step began. |
step.completed | A step completed. |
step.failed | A step failed. |
step.skipped | A step was skipped (output was already available from a checkpoint). |
step.retrying | A step's LLM call failed transiently and is being retried. |
step.progress | Mid-step progress update (LLM streaming, batch progress). |
artifact.created | An artifact is ready to fetch. |
Poll Events
curl "https://havilo.ai/api/v1/runs/550e8400-e29b-41d4-a716-446655440000/events?limit=50" \
-H "Authorization: Bearer $HAVILO_API_KEY"{
"object": "list",
"data": [
{
"id": "evt_01hxbexample",
"event_id": "evt_01hxbexample",
"event_type": "run.started",
"sequence": 1714500001000000,
"occurred_at": 1714500001000,
"run_id": "550e8400-e29b-41d4-a716-446655440000",
"api_version": "2026-04-22",
"data": {
"status": "running",
"step_count": 2
}
}
],
"next_cursor": "eyJhdCI6IjIwMjQtMDQtMzBUMTg6MDA6MDEuMDAwWiIsImlkIjoiZXhhbXBsZSJ9",
"has_more": false
}Every response carries a next_cursor, including the last page. Pass it back as cursor to read only what came after it. When has_more is true, fetch again right away. When it is false you are caught up, and the same cursor returns new events once the run emits them. An empty page returns the cursor you sent.
Add keep-open=1 to wait up to 25 seconds for a new event instead of getting an empty page.
The cursor is opaque; do not decode it. A cursor this endpoint did not issue returns 422 schema_validation_failed with param: "cursor".
Create an Event Stream Token
SSE streams use a short-lived event stream token bound to the run.
curl -X POST https://havilo.ai/api/v1/runs/550e8400-e29b-41d4-a716-446655440000/events/token \
-H "Authorization: Bearer $HAVILO_API_KEY"{
"event_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.example",
"expires_at": 1714500300000
}Stream Events with SSE
curl -N "https://havilo.ai/api/v1/runs/550e8400-e29b-41d4-a716-446655440000/events/stream?event_token=$EVENT_STREAM_TOKEN"EventSource clients in browsers cannot send an Authorization header, so SSE auth uses the event_token query parameter instead. Obtain a token via POST /runs/{id}/events/token (5-minute TTL).
Each SSE message includes the full event object:
id: evt_01hxbexample
event: artifact.created
data: {"id":"evt_01hxbexample","event_id":"evt_01hxbexample","event_type":"artifact.created","sequence":1714500018000001,"occurred_at":1714500018000,"run_id":"550e8400-e29b-41d4-a716-446655440000","api_version":"2026-04-22","data":{"artifact_id":"550e8400-e29b-41d4-a716-446655440001","kind":"table","title":"Climate-tech Startups"}}The id: line carries the event object's event_id. Each event is sent once per connection.
Resume after a disconnect
Send the last event_id you received and the stream starts right after it:
EventSourcedoes this for you. When it reconnects it sends the lastid:it saw as theLast-Event-IDheader.- When you open a new connection yourself, pass it as
last_event_id=<event_id>.
If both are present, the header wins. An event_id this run never emitted returns 422 schema_validation_failed. Resuming at the run's final event (run.succeeded, run.failed or run.canceled) with nothing after it returns 204, which tells EventSource to stop reconnecting.
Heartbeats
After 15 seconds with no events, the server sends a comment frame to keep the connection alive through proxies:
: keep-aliveLines starting with : are SSE comments. Ignore them.
Connection lifetime and reconnect
SSE connections are hard-capped at 10 minutes. When the cap is reached the server emits a final connection.expired event and closes the stream. To reconnect:
- Call
POST /runs/{id}/events/tokento obtain a freshevent_token(refresh proactively before the 5-minute TTL expires). - Open a new stream with the fresh token and the last
event_idyou received:
curl -N "https://havilo.ai/api/v1/runs/550e8400-e29b-41d4-a716-446655440000/events/stream?event_token=$NEW_TOKEN&last_event_id=$LAST_EVENT_ID"Browser example
Every frame is a named event, so es.onmessage never fires. Listen for each event type you need with addEventListener.
const url = `https://havilo.ai/api/v1/runs/${runId}/events/stream?event_token=${token}`
const es = new EventSource(url)
es.addEventListener('step.completed', (e) => {
const event = JSON.parse(e.data)
console.log('step done', event.data.step_index, event.data.status, event.data.duration_ms)
})
es.addEventListener('run.succeeded', (e) => {
const event = JSON.parse(e.data)
console.log('run done', event.data.step_count)
es.close()
})
es.onerror = () => {
// EventSource reconnects on its own and resumes via Last-Event-ID. After token expiry or the
// 10-minute cap, close it, get a fresh token, and reconnect with last_event_id.
}Webhooks
Provide callback_url on POST /runs to receive run lifecycle webhooks. Step-level progress is available only through polling or SSE.