# Events

Source: https://docs.havilo.ai/reference/ai-engine/events

Events expose run progress for polling, webhook consumers, and server-sent event streams.

## Event Object

```json
{
  "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

```bash
curl "https://havilo.ai/api/v1/runs/550e8400-e29b-41d4-a716-446655440000/events?limit=50" \
  -H "Authorization: Bearer $HAVILO_API_KEY"
```

```json
{
  "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.

```bash
curl -X POST https://havilo.ai/api/v1/runs/550e8400-e29b-41d4-a716-446655440000/events/token \
  -H "Authorization: Bearer $HAVILO_API_KEY"
```

```json
{
  "event_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.example",
  "expires_at": 1714500300000
}
```

## Stream Events with SSE

```bash
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:

```txt
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:

* `EventSource` does this for you. When it reconnects it sends the last `id:` it saw as the `Last-Event-ID` header.
* 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:

```txt
: keep-alive
```

Lines 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:

1. Call `POST /runs/{id}/events/token` to obtain a fresh `event_token` (refresh proactively before the 5-minute TTL expires).
2. Open a new stream with the fresh token and the last `event_id` you received:

```bash
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`.

```js
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.

> **Verify every webhook before acting on it**
>
> See [Webhooks](https://docs.havilo.ai/reference/ai-engine/webhooks.md) for the HMAC signature scheme, retry behavior,
> and consumer checklist.
