> ## Documentation Index
> Fetch the complete documentation index at: https://docs.labelbox.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Events

> Send messages to a session, stream and read its events without losing any, and watch many sessions at once.

Everything that happens in a session is recorded as an ordered event: your messages, the agent's replies, tool calls, and status changes. Send events to steer a session, and stream or list them to follow it.

<Note>
  You need a session id; see [Sessions](/recursion/sessions). Sending messages needs the organization developer or admin role. The organization user role can read and stream events. The billing role cannot see sessions. See [Organizations and roles](/recursion/organizations-and-roles). For API calls, create a key under **API keys**; see [API keys](/recursion/api-keys).
</Note>

## Send messages

`sendSessionEvents` adds turns to a session. Send `message` for one plain-text turn, or `events` for typed turns. If you send both, `events` wins and `message` and the top-level `actor` are ignored.

<Tabs>
  <Tab title="Console">
    Open the session, type in **Message the agent…**, and click **Send**. While the agent is working the button reads **Queue**, and **Interrupt & send** appears beside it.
  </Tab>

  <Tab title="cURL">
    ```bash theme={"theme":"css-variables"}
    curl -X POST 'https://api.recursion.labelbox.com/managed-agents/v1/sessions/e3a91f5c-7d24-4b68-9c10-2f8e6b4d7a53/events' \
      -H "Authorization: Bearer $RECURSION_API_KEY" \
      -H 'Content-Type: application/json' \
      -d '{
        "events": [
          {"type": "user.message", "text": "Use the incident tracker as the source of truth."},
          {"type": "system.message", "content": [{"type": "text", "text": "Reply in British English."}]}
        ]
      }'
    ```
  </Tab>
</Tabs>

```json theme={"theme":"css-variables"}
{ "ok": true, "delivery_state": "queued", "events_accepted": 2 }
```

The response confirms that your turns were stored. It does not include the agent's reply; read that from the stream or the event list. Compare `events_accepted` with the number of events you sent.

| `delivery_state` | Meaning |
| - | - |
| `queued` | The agent is mid-turn. It reads your message at the end of the current turn. [Interrupt](/recursion/sessions) to have it read now. Until then the message appears in pending inputs, not the transcript. See [Session operations](/recursion/session-operations). |
| `signaled` | The session was waiting, and your message woke it. |
| `resumed` | The session was idle or finished, and your message started a new run. Only root sessions resume. |
| `stored` | The message was saved, but nothing is running to read it yet. |

<Accordion title="Event fields and send rules">
  | Event field | Description |
  | - | - |
  | `type` | `user.message` (the default), `system.message`, or `handoff_resolved`. Any other value fails the request with `400` naming that entry, and nothing is appended. |
  | `text` | Shorthand for one text block. Ignored when `content` is set. |
  | `content` | Content blocks, such as `[{"type": "text", "text": "..."}]`. Wins over `text`. A `system.message` uses only `content`. |
  | `actor` | Who a `user.message` is attributed to. Defaults to `human:api`. A `system.message` ignores it. |

  A `user.message` with neither `text` nor `content` is dropped without an error. If every entry is dropped, the request also fails with `400`.

  The top-level `referenced_session_ids` grants the session read access to up to 10 earlier sessions from this turn on. If you cannot read one of them, the whole request fails with `404` and no message is delivered. See [Referenced sessions](/recursion/referenced-sessions).

  A top-level `image` or `document` block can name a file from your library instead of carrying bytes. See [Show a file to the agent in a message](/recursion/files#show-a-file-to-the-agent-in-a-message).

  A message to a session that is still being cancelled returns a retryable `409 conflict`. A message to a subagent session in a terminal status also returns `409 conflict`, because only root sessions resume.
</Accordion>

<Accordion title="Retry a send safely">
  To retry a send after an ambiguous network failure without adding the turn twice, send one stable `Idempotency-Key` header and resend the same request. See [Idempotent mutations](/recursion/api#idempotent-mutations).
</Accordion>

<Accordion title="Hand a browser step back">
  When a session is `awaiting_human`, the agent is waiting for a person to handle a step in its browser display, such as a sign-in. Its `active_handoff` names the request. Send a `handoff_resolved` event with that `handoff_id` to let the agent continue, with an optional `note` of up to 2,000 characters telling it what you did. It must be the only event in the request.

  ```bash theme={"theme":"css-variables"}
  curl -X POST 'https://api.recursion.labelbox.com/managed-agents/v1/sessions/e3a91f5c-7d24-4b68-9c10-2f8e6b4d7a53/events' \
    -H "Authorization: Bearer $RECURSION_API_KEY" \
    -H 'Content-Type: application/json' \
    -d '{"events": [{"type": "handoff_resolved", "handoff_id": "7c2e9a41-5b38-4d06-a1f7-3e8c6b2d9f50", "note": "Signed in to the vendor portal."}]}'
  ```

  A `handoff_id` that doesn't match the active hand-off, or a session with none, returns `409 conflict`. Without this event, the agent resumes on its own when the hand-off deadline passes.
</Accordion>

## Stream events

`streamSessionEvents` is a server-sent events (SSE) stream of a session's events as they are recorded. It replays everything after your cursor first, then stays open for new events. The stream closes on its own only when the session reaches a terminal status: `completed`, `failed`, or `cancelled`. An interrupted or sleeping session keeps it open.

<Tabs>
  <Tab title="Console">
    Open the session. The transcript streams live.
  </Tab>

  <Tab title="cURL">
    ```bash theme={"theme":"css-variables"}
    curl -N 'https://api.recursion.labelbox.com/managed-agents/v1/sessions/e3a91f5c-7d24-4b68-9c10-2f8e6b4d7a53/events/stream' \
      -H "Authorization: Bearer $RECURSION_API_KEY"
    ```
  </Tab>
</Tabs>

```text theme={"theme":"css-variables"}
: connected

event: agent.message
id: 01997a3d-0b82-7c15-9a43-6e1f2d8c5b09
data: {"type":"agent.message","id":"01997a3d-0b82-7c15-9a43-6e1f2d8c5b09","session_id":"e3a91f5c-7d24-4b68-9c10-2f8e6b4d7a53","processed_at":"2026-09-25T14:05:40Z","content":[{"type":"text","text":"Found 12 open incidents."}],"raw_event":{"event_type":"message"}}

: ping
```

Each event is sent as `event: <type>`, `id: <event_id>`, and `data: <json>`. Save the `id` of each frame you handle: it is your position if the connection drops. See [Recover from a dropped stream](#recover-from-a-dropped-stream).

<Accordion title="Stream parameters">
  | Parameter | Description |
  | - | - |
  | `after_event_id` | Start after this event id. Takes precedence over the `Last-Event-ID` header. |
  | `Last-Event-ID` header | Start after this event id when `after_event_id` is not set. Standard SSE clients send it on reconnect. |
  | `types` | Only send these frame types, comma-separated (`types=agent.message,session.status_idle`) or repeated as `types[]`. Filtered-out events still move your position forward. |
</Accordion>

<Accordion title="Every frame type">
  The frame's JSON has `type`, `id`, `session_id`, `session_thread_id`, `processed_at`, `content`, `stop_reason`, `usage`, `error`, `anchor_event_id`, and `raw_event`, which holds the full stored event.

  | Frame `type` | Sent for |
  | - | - |
  | `user.message`, `agent.message`, `system.message` | Messages, by role. |
  | `agent.tool_use`, `agent.tool_result` | Tool calls and their results. |
  | `agent.approval_request`, `user.tool_confirmation` | Approval requests and decisions. |
  | `agent.artifact` | A saved deliverable. |
  | `agent.thread_context_compacted` | A thread's context was compacted. |
  | `session.status_provisioning`, `session.status_queued`, `session.status_running` | The session entered that state. |
  | `session.status_idle` | The session went idle or completed. |
  | `session.status_terminated` | The session failed or was cancelled. |
  | `session.status` | Any other status change. |
  | `session.event` | Everything else: summaries, plan updates, turn commits, advisor notes, and board updates. Read `raw_event.event_type`. |
  | `session.error` | A problem with the stream. See **Stream errors and record size**. |

  The stream also sends frames without an `id`. These are not stored events and do not move your position:

  * `: connected` is the first line. `: ping` follows every 15 seconds. They keep the connection alive; ignore them.
  * When you connect to a running session, a `session.status_running` frame tells you the current state.
  * When the session reaches a terminal status, the stream sends one last `session.status_idle` (completed) or `session.status_terminated` (failed or cancelled), with `stop_reason` and, on failure, `error`. Then it closes.
</Accordion>

<Accordion title="Stream errors and record size">
  | Frame | Meaning | What to do |
  | - | - | - |
  | `session.error` with an `id` and `error.code: event_record_too_large` | One event was too large to send. `error.content_path_template` gives the path to fetch it. The stream continues. | Fetch it with `getSessionEventContent`, or list events with `payloads=refs`. |
  | `session.error` without an `id`, with `error.message` | The server could not read events. The stream closes. | Reconnect from your last event id. |

  The `Content-Type` of the response is `text/event-stream; charset=utf-8; max-record-bytes=<N>`, where `<N>` is the largest record the server may send. Size your SSE parser's buffer to that value. If a record is larger than your client accepts, list events after your last id with `payloads=refs`, then resume the stream after the event you fetched.
</Accordion>

## Recover from a dropped stream

Networks drop connections. The stream is designed so you can reconnect without losing events.

<Steps>
  <Step title="Track the last event you finished processing">
    Save a frame's `id` only after you have handled it. Frames without an `id` never change your position.
  </Step>

  <Step title="Reconnect from that id">
    Reconnect with `Last-Event-ID: <id>`, or with `after_event_id=<id>` in the URL. The server replays every event after it. If you use `after_event_id`, update it on every reconnect; reusing the original URL replays from the original position. A malformed id returns `400`. A well-formed id from another session does not fail; it is treated as a position.
  </Step>

  <Step title="Back off between attempts">
    Retry with exponential backoff and jitter. A `503` means the server is at its stream capacity; retry after a short wait.
  </Step>

  <Step title="Deduplicate by event id">
    Replays can repeat an event you already saw, so keep a set of handled `event_id` values and skip repeats.
  </Step>

  <Step title="Reconcile when the session ends">
    When several agents write at once, an event can be recorded slightly after a later-sorting event was already sent to you, and a cursor reader can miss it. After the stream closes at a terminal status, page through `listSessionEvents` from the start and add any event id you have not seen.
  </Step>
</Steps>

<Tip>
  If your process restarts and loses its place, list events with `listSessionEvents` to rebuild state, then open the stream with `after_event_id` set to the last id you listed.
</Tip>

## List events

`listSessionEvents` returns events oldest first, in pages. Any session id in a tree returns the combined timeline of the whole tree, including subagents.

<Tabs>
  <Tab title="Console">
    Open the session. The transcript shows every event, and the **Events** tab lists them with their raw fields.
  </Tab>

  <Tab title="cURL">
    ```bash theme={"theme":"css-variables"}
    curl 'https://api.recursion.labelbox.com/managed-agents/v1/sessions/e3a91f5c-7d24-4b68-9c10-2f8e6b4d7a53/events?limit=200' \
      -H "Authorization: Bearer $RECURSION_API_KEY"
    ```
  </Tab>
</Tabs>

```json theme={"theme":"css-variables"}
{
  "events": [
    {
      "event_id": "01997a3c-5e21-7b4d-8f10-3c6a2e9d4b71",
      "event_type": "message",
      "actor": "human:api",
      "role": "user",
      "session_id": "e3a91f5c-7d24-4b68-9c10-2f8e6b4d7a53",
      "content": {
        "blocks": [{ "type": "text", "text": "Use the incident tracker as the source of truth." }]
      },
      "event_status": "completed",
      "created_at": "2026-09-25T14:05:02Z"
    }
  ],
  "next_page_token": "<id of the last event on this page>"
}
```

To read the next page, pass `next_page_token` as `after_event_id`. An empty token means you have read everything that exists right now.

<Accordion title="List parameters and paging">
  | Parameter | Description |
  | - | - |
  | `after_event_id` | Return events after this id, not including it. Must be a lowercase event id; a malformed value returns `400`. |
  | `limit` | Events per page. Default 100. A page also stops at about 8 MiB of data, so it can hold fewer events than `limit`. |
  | `hydrate` | `images` inlines image data in image blocks. |
  | `image_urls` | `signed` adds a short-lived `url` and `url_expires_at` to image blocks. A URL can be left out when many images are signed at once; fetch those with [Get an image](#get-an-image). |
  | `payloads` | `refs` returns large tool payloads as references instead of inline, which keeps pages small. |

  Any other value for `hydrate`, `image_urls`, or `payloads` returns `400`.

  **Pagination.** `next_page_token` is always present and equals the id of the last event on the page. A short page with a non-empty token does not mean the end. `events` can be `null` on the last page.

  **Event ids.** Event ids sort in the order events were created, so an id works as a position. An `after_event_id` that is well formed but not in this session is not an error: you get the events that sort after it.

  **Oversized events.** An event too large for a page is returned alone, with `content_hydration_status: oversized`. Fetch its full content with [Get full event content](#get-full-event-content).
</Accordion>

<Accordion title="Event fields">
  | Field | Description |
  | - | - |
  | `event_id` | Unique, time-ordered id. Use it as a cursor and to deduplicate. |
  | `event_type` | `message`, `tool_invocation`, `tool_result`, `approval_request`, `approval_decision`, `summary`, `plan_update`, `session_status`, `artifact`, `advisor_intervention`, `board_update`, `thread_context_compacted`, or `turn_committed`. |
  | `actor`, `role` | Who produced the event. `role` is `user`, `assistant`, or `system` on messages. |
  | `content` | `blocks`, plus `stop_reason`, `usage`, `plan`, and `metadata` where they apply. |
  | `event_status` | `pending`, `completed`, `warning`, `error`, or a lifecycle value on `session_status` events. |
  | `session_id`, `thread_id` | Which session and thread produced the event. See [Multi-agent sessions](/recursion/multi-agent). |
  | `tool_name`, `tool_use_id` | On tool calls and results. A result shares `tool_use_id` with its call. |
  | `parent_event_id`, `causal_event_id`, `turn_id` | Links to related events and the turn. |
  | `input_tokens`, `output_tokens`, `cost_micros` | Usage on model events. `cost_micros` is for diagnostics, not what you are charged. See [Usage and cost](/recursion/usage-and-cost). |
  | `content_hydration_status` | Set when content was not returned in full. |
  | `created_at` | When the event was recorded. |
</Accordion>

### Get full event content

`getSessionEventContent` returns the full `content` of one event. Use it for oversized events or payload references. The event must belong to the session. The response carries an `ETag`.

```bash theme={"theme":"css-variables"}
curl 'https://api.recursion.labelbox.com/managed-agents/v1/sessions/e3a91f5c-7d24-4b68-9c10-2f8e6b4d7a53/events/01997a3c-5e21-7b4d-8f10-3c6a2e9d4b71/content' \
  -H "Authorization: Bearer $RECURSION_API_KEY"
```

### Get an image

Image blocks in events carry an image URI. `getSessionImage` returns the image bytes for it, with the image's media type. Call it with an HTTP client or cURL. The URI must come from an event in the same session tree. In the console, images show inline in the transcript.

```bash theme={"theme":"css-variables"}
curl -G 'https://api.recursion.labelbox.com/managed-agents/v1/sessions/e3a91f5c-7d24-4b68-9c10-2f8e6b4d7a53/images' \
  --data-urlencode 'uri=<the image URI from the event>' \
  -H "Authorization: Bearer $RECURSION_API_KEY" \
  -o image.png
```

To skip this call, list events with `image_urls=signed` and download from each block's short-lived `url`.

## Watch many sessions with the gestalt

The gestalt is a live, compact summary of every root session in a time window: its status, execution state, and last activity. It is built for dashboards and alerting. Use it instead of polling `listSessions` or opening one event stream per session. Take one snapshot, draw it, then keep it current with the gestalt stream.

```bash theme={"theme":"css-variables"}
curl 'https://api.recursion.labelbox.com/managed-agents/v1/sessions/gestalt?since=2026-09-18T00:00:00Z&agent_id=b7e1c9a4-3d62-4f15-8a07-5c2e9f6d1b38' \
  -H "Authorization: Bearer $RECURSION_API_KEY"
```

```json theme={"theme":"css-variables"}
{
  "as_of": "2026-09-25T14:20:03.512Z",
  "sessions": [
    {
      "session_id": "e3a91f5c-7d24-4b68-9c10-2f8e6b4d7a53",
      "agent_id": "b7e1c9a4-3d62-4f15-8a07-5c2e9f6d1b38",
      "status": "active",
      "execution_state": "running",
      "created_at": "2026-09-25T14:02:11Z",
      "updated_at": "2026-09-25T14:19:48Z",
      "last_activity_at": "2026-09-25T14:19:48Z"
    }
  ]
}
```

`since` is required, in RFC 3339 format. It can be at most 30 days ago, or 400 days ago when you also pass `agent_id`. Keep the snapshot's `as_of` for the stream.

<Accordion title="Snapshot fields">
  Sessions are ordered newest first by creation time. A row has `failed: true` when the session ended in a failure. `truncated: true` means only the newest 5,000 were returned; narrow the window or add `agent_id`. Both fields are left out when false.
</Accordion>

### Keep the snapshot current

Open `streamSessionGestalt` with the same `since` and `agent_id`, and `after` set to the snapshot's `as_of`.

```bash theme={"theme":"css-variables"}
curl -N 'https://api.recursion.labelbox.com/managed-agents/v1/sessions/gestalt/stream?since=2026-09-18T00:00:00Z&agent_id=b7e1c9a4-3d62-4f15-8a07-5c2e9f6d1b38&after=2026-09-25T14:20:03.512Z' \
  -H "Authorization: Bearer $RECURSION_API_KEY"
```

On each `gestalt.change` frame, replace each row by `session_id` and remove rows with `deleted: true`. The gestalt stream never closes on its own otherwise, so close it when your dashboard is done.

<Accordion title="Gestalt frames and reconnecting">
  | Frame | Meaning | What to do |
  | - | - | - |
  | `: connected`, `: ping` | Keep-alive. Nothing is sent while nothing changes. | Ignore. |
  | `gestalt.change` | `data` is `{as_of, sessions}` with the rows that changed. Its `id` is the new `as_of`. | Replace each row by `session_id`. Remove rows with `deleted: true`. |
  | `gestalt.reset` | The server cannot continue from your position. The stream closes. | Take a new snapshot and reconnect from its `as_of`. |
  | `gestalt.error` | The server hit an error. The stream closes. | Reconnect from the last `id` you received. If it repeats, take a new snapshot. |

  To reconnect, send the last frame `id` as `after` or as the `Last-Event-ID` header. One of them is required. A stream position older than 30 days is rejected; take a new snapshot.
</Accordion>

## What can go wrong

The most common problems:

* **The reply never arrives after `delivery_state: queued`.** The agent is still on its current turn. Wait, or interrupt the session.
* **`events_accepted` is lower than the events you sent.** Some entries were dropped. Check each entry's `type` and content.
* **The stream replays the same events after every reconnect.** The URL still has the original `after_event_id`. Update or remove it on reconnect.
* **The stream stays open after the agent stopped.** The session is interrupted or sleeping, not terminal. Close the stream yourself, or cancel the session.

<Accordion title="Every events error">
  | Symptom or code | Cause | Fix |
  | - | - | - |
  | `400 invalid_request` on `events` | Every event was dropped: unknown types or empty messages. | Send `text` or `content` on each `user.message`. |
  | `events_accepted` is lower than the events you sent | Some entries were dropped. | Check each entry's `type` and content. |
  | `404 not_found` when sending | The session or a referenced session is missing or not visible to you. | Check the ids and your access. |
  | `409 conflict` when sending | The session is still being cancelled. | Retry after a few seconds. |
  | The reply never arrives after `delivery_state: queued` | The agent is still on its current turn. | Wait, or interrupt the session. |
  | `409 conflict` when messaging a subagent | Subagent sessions in a terminal status do not resume. | Message the root session. |
  | `400 invalid_request` on `after_event_id` | The id is malformed or not lowercase. | Pass an `event_id` exactly as returned. |
  | The stream replays the same events after every reconnect | The URL still has the original `after_event_id`. | Update or remove it on reconnect. |
  | `503 service_unavailable` when opening a stream | The server is at its stream capacity. | Retry with backoff. |
  | `session.error` with `event_record_too_large` | One event is larger than a stream record. | Fetch it with `getSessionEventContent`. |
  | An event is missing from a long stream | A concurrent write was recorded out of order. | Re-read events after the session reaches a terminal status. |
  | The stream stays open after the agent stopped | The session is interrupted or sleeping, not terminal. | Close the stream yourself, or cancel the session. |
  | `gestalt.reset` | Your gestalt position can no longer be continued. | Take a new snapshot. |
  | `400 invalid_request` on `since` | The window is older than 30 days (400 with `agent_id`), or not RFC 3339. | Shorten the window or add `agent_id`. |

  Every error has required `code` and `message` fields plus optional `details`. When present, details can include `field`, `requestId`, `retryable`, and code-specific context. Use the generated **Endpoints** reference for exact alternatives and [Errors](/recursion/errors) for recovery guidance.
</Accordion>

Limits on referenced sessions, event pages, stream keep-alive, and the gestalt are in [Limits](/recursion/limits).

## Next steps

<CardGroup cols={2}>
  <Card title="Sessions" icon="play" href="/recursion/sessions">
    Start, interrupt, cancel, and delete sessions.
  </Card>

  <Card title="Session operations" icon="gauge" href="/recursion/session-operations">
    Pending inputs, compute usage, the analyst, and the session tree.
  </Card>

  <Card title="Multi-agent sessions" icon="sitemap" href="/recursion/multi-agent">
    Read events across threads and subagents.
  </Card>

  <Card title="Deliverables and artifacts" icon="box-archive" href="/recursion/artifacts">
    Ask for deliverables and see how they're kept.
  </Card>
</CardGroup>
