# Events (SSE)

> Catalog of a conversation's stream events — started, chunk, tool, permission, artifact, done — and how to write a client that doesn't miss anything.

URL: https://www.ghosty.studio/en/docs/api/events

`GET /api/v2/me/agents/:agentId/conversations/:sessionId/events` is a **Server-Sent Events** stream with named events (`event: chunk`), always with `data` (even if it is `{}`).

## Three rules that surprise people

1. **It is re-subscribable.** On connecting you first receive everything the current turn already emitted, then the live feed. An F5, or coming back tomorrow, loses nothing. Disconnecting **does not cancel** the turn: the work belongs to the server.
2. **At rest you get a `done` right away**, flagged `reposo: true` ("idle"), so you can tell "no turn" from "a turn finished".
3. **Open permissions are re-sent on every subscription.** The server doesn't know which screens are still alive. Dismiss them with `permission-resolved`, which also covers another client answering.

There is a heartbeat every 25 s so no proxy cuts a long turn for silence.

## Catalog

| Event | `data` | When |
|---|---|---|
| `started` | `{}` | Connection established. |
| `caps` | `{ image: boolean }` | Agent capabilities (whether it sees images). |
| `status` | `{ phase: "waking" \| "session" }` | The machine is waking up / the turn is in session. |
| `chunk` | `{ text, turnId }` | A piece of the reply. Concatenate them by `turnId`. |
| `thought` | `{ text }` | Visible reasoning from the model, if the engine exposes it. |
| `tool` | `{ id, title?, kind?, status, path?, detalle? }` | Tool lifecycle: `pending → in_progress → completed \| failed`. Same `id` on every change. `detalle` = detail. |
| `title` | `{ title }` | The conversation was named. |
| `models` | `{ options: [{value,name}], current }` | Available models and the current one. |
| `usage` | `{ used, size, cost, input?, output? }` | Accumulated session tokens. `used` may exceed `size` after a compaction: it is not a context gauge. |
| `permission` | `{ id, title, tool?, options: [{optionId,name,kind?}] }` | The agent asks for confirmation. Answer with `POST …/permission { id, optionId }`. Ten minutes without a reply = denied. |
| `permission-resolved` | `{ id }` | That request was closed (by you, by another client or by timeout). |
| `artifact` | `{ … }` | The agent delivered something: file, document, sheet. Carries a signed `url` and a type. |
| `done` | `{ turnId }` or `{ reposo: true }` | The turn finished / there is no turn. |
| `error` | `{ message }` | The turn failed. Partial text already received is still valid. |

## Minimal client in Node

:::tabs
```typescript tab=TypeScript
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}`, Accept: "text/event-stream" } });
const reader = res.body!.pipeThrough(new TextDecoderStream()).getReader();
let buf = "";
for (;;) {
  const { value, done } = await reader.read();
  if (done) break;
  buf += value;
  let i;
  while ((i = buf.indexOf("\n\n")) !== -1) {
    const frame = buf.slice(0, i); buf = buf.slice(i + 2);
    const event = /^event: (.*)$/m.exec(frame)?.[1];
    const data = /^data: (.*)$/m.exec(frame)?.[1];
    if (!event || data == null) continue;
    handle(event, JSON.parse(data));
  }
}
```
```python tab=Python
import json, requests
with requests.get(url, headers={"Authorization": f"Bearer {token}"}, stream=True) as r:
    event = None
    for line in r.iter_lines(decode_unicode=True):
        if line.startswith("event: "): event = line[7:]
        elif line.startswith("data: ") and event:
            handle(event, json.loads(line[6:])); event = None
```
:::
## Retries

If the connection drops, open it again: you get the replay and carry on. To find out whether the turn finished while you were away, look at `ultimoTurno` ("last turn") in `GET …/conversations/:sessionId`.
