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

# SSE Events

> The complete streaming event vocabulary: 20 public event names, payload fields, and consumption rules

# SSE Events

Streaming endpoints return `text/event-stream`. This page is the **complete list of the public event vocabulary**. Anything not on this table is an internal event passed through verbatim — it is not a contract and may change.

## Wire format

Each event is two lines plus a blank line:

```
event: output.text.delta
data: {"delta":"Hello","event_id":12,"type":"output.text.delta"}

```

Three conventions:

* `data` is single-line JSON
* Every payload carries a **monotonically increasing** `event_id` starting at 1, usable for dedup and gap detection
* The `type` field mirrors the `event:` line, so parsing only `data` is sufficient

The stream terminator is:

```
data: [DONE]
```

<Warning>
  `[DONE]` is not JSON. Calling `JSON.parse(line.slice(6))` on it throws `SyntaxError: Unexpected token D`. This is the single most common first-integration failure — check for this line before parsing.
</Warning>

## Full event table

Internal event names map to public names through a fixed table. **The public name is the contract; the internal name is not** — internal renames do not affect you, while public-name changes go through a version process.

### Run lifecycle

| Public event      | Internal     | Meaning                                                                               |
| ----------------- | ------------ | ------------------------------------------------------------------------------------- |
| `run.created`     | `preparing`  | Request accepted; execution environment (sandbox, context, toolset) is being prepared |
| `run.in_progress` | `turn_start` | A reasoning turn begins                                                               |
| `run.completed`   | `complete`   | Run finished normally                                                                 |
| `run.failed`      | `error`      | Run failed; payload carries the error                                                 |

`run.in_progress` fires once per turn. Within one run the model may call tools, read results, and reason again — each round is a turn, so **receiving multiple `run.in_progress` events is normal**. Do not treat them as duplicate requests.

### Output content

| Public event             | Internal          | Meaning                |
| ------------------------ | ----------------- | ---------------------- |
| `output.text.delta`      | `text`            | Answer text increment  |
| `output.thinking.delta`  | `reasoning`       | Reasoning increment    |
| `output.thinking.status` | `thinking_status` | Reasoning phase status |

<Warning>
  For `text` and `reasoning`, the content field is **renamed from `content` to `delta`** during mapping. This is deliberate (alignment with the OpenAI Responses API), but it means every consumer must read `delta`.

  The safe form is `event.delta ?? event.content` — accept both names so pass-through events and version skew never silence your output.
</Warning>

Assembling the answer means concatenating every `output.text.delta` `delta` in arrival order. Do not reorder by `event_id`; events already arrive in order and reordering only adds a failure mode.

### Tool calls

Tool calls split into several events; the first four come from the internal `tool_call` event dispatched on its `status` field:

| Public event                | Trigger                     | Meaning                                   |
| --------------------------- | --------------------------- | ----------------------------------------- |
| `tool_call.created`         | `status=pending`            | The model decided to call a tool          |
| `tool_call.in_progress`     | `status=running`            | The tool started executing                |
| `tool_call.completed`       | `status=completed`          | The tool succeeded                        |
| `tool_call.failed`          | `status=failed`             | The tool failed                           |
| `tool_call.arguments.delta` | internal `tool_call_chunk`  | Streaming increments of tool arguments    |
| `tool_call.result`          | internal `tool_call_result` | The tool's result payload                 |
| `tool_call.denied`          | internal `tool_denied`      | The tool was blocked by permission policy |

<Note>
  Unlisted `tool_call.<something>` names are possible: any `status` outside the mapping table produces `tool_call.${status}` verbatim. Your UI needs a fallback renderer for the `tool_call.*` prefix rather than a hard-coded four-case switch whose default branch does nothing.
</Note>

`tool_call.denied` and `tool_call.failed` are different things:

* `failed` = the tool ran and errored (timeout, invalid argument, missing target)
* `denied` = the tool never ran; a permission gate stopped it

Collapsing both into "tool error" gives users a Retry button that can never succeed.

### Approvals and delegation

| Public event           | Internal               | Meaning                                    |
| ---------------------- | ---------------------- | ------------------------------------------ |
| `approval.required`    | `Ask`                  | Human confirmation is required to continue |
| `delegation.started`   | `delegation_started`   | Delegating to another expert               |
| `delegation.completed` | `delegation_completed` | Delegated work finished                    |

`approval.required` is a **blocking event**: the stream waits for your response. Ignore it and the run hangs until timeout.

### Usage and quota

| Public event             | Internal           | Meaning                          |
| ------------------------ | ------------------ | -------------------------------- |
| `usage.token`            | `token_usage`      | Token usage report               |
| `usage.budget_exhausted` | `budget_exhausted` | Budget exhausted; run terminated |
| `usage.trial_exhausted`  | `trial_exhausted`  | Trial turns exhausted            |

<Warning>
  `usage.trial_exhausted` needs its own branch. It is not an error — it means "this user's trial is used up and a purchase is next."

  Routing it into a generic error branch shows the user something like "operation refused," which carries no information and reads as an account fault. IM channels once did exactly this, and every user whose trial ended believed the system was broken. Handle this event by surfacing the purchase entry point.
</Warning>

## Consumption example

<CodeGroup>
  ```typescript TypeScript theme={null}
  const res = await fetch(url, { method: "POST", headers, body });
  const reader = res.body!.getReader();
  const decoder = new TextDecoder();
  let buffer = "";
  let text = "";

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });

    const lines = buffer.split("\n");
    buffer = lines.pop() ?? "";

    for (const line of lines) {
      if (!line.startsWith("data: ")) continue;

      const payload = line.slice(6);
      if (payload === "[DONE]") return text;   // check first, or JSON.parse throws

      const evt = JSON.parse(payload);
      switch (evt.type) {
        case "output.text.delta":
          text += evt.delta ?? evt.content ?? "";
          break;
        case "usage.trial_exhausted":
          showPurchasePrompt();               // do not fall into the error branch
          break;
        case "run.failed":
          throw new Error(evt.message ?? "run failed");
        default:
          if (evt.type?.startsWith("tool_call.")) renderToolCall(evt);
      }
    }
  }
  ```

  ```python Python theme={null}
  import json

  async for line in response.aiter_lines():
      if not line.startswith("data: "):
          continue

      payload = line[6:]
      if payload == "[DONE]":
          break

      evt = json.loads(payload)
      t = evt.get("type")

      if t == "output.text.delta":
          print(evt.get("delta") or evt.get("content") or "", end="", flush=True)
      elif t == "usage.trial_exhausted":
          handle_trial_exhausted()
      elif t == "run.failed":
          raise RuntimeError(evt.get("message", "run failed"))
  ```
</CodeGroup>

## Boundaries and failure modes

| Symptom                           | Cause                               | Fix                                   |
| --------------------------------- | ----------------------------------- | ------------------------------------- |
| `SyntaxError: Unexpected token D` | Parsing `[DONE]` as JSON            | Check for that line before parsing    |
| Events arrive but text is empty   | Reading only `content`, not `delta` | Use `delta ?? content`                |
| Unknown event name                | Internal event passed through       | Ignore it; do not throw               |
| Stream ends without `[DONE]`      | Network drop or server fault        | Treat as incomplete, not as success   |
| Unknown `tool_call.` suffix       | `status` outside the four-value map | Fall back on the `tool_call.*` prefix |
| Multiple `run.in_progress`        | Multi-turn reasoning, one per turn  | Normal; do not dedup to one           |

<Note>
  Unrecognised event types are **passed through, not dropped**. That is deliberate: new internal events do not vanish on your side just because the mapping table lags. The cost is that you must tolerate unknown names — so your `default` branch should ignore, not throw.
</Note>

## Verify your integration

Minimal check:

```bash theme={null}
curl -N https://api.profy.cn/v1/agents/run \
  -H "Authorization: Bearer $PROFY_API_KEY" \
  -H "X-End-User-Id: verify-1" \
  -H "Content-Type: application/json" \
  -d '{"agent":"your-expert","message":"Say one sentence"}' \
  | head -40
```

You should see:

1. `event: run.created` first
2. Several `event: output.text.delta`
3. `event: run.completed`
4. `data: [DONE]` last

If the first event is `run.failed`, read the code in the payload and look it up in [Error Codes](/en/developers/error-codes).

## Related pages

<CardGroup cols={2}>
  <Card title="Endpoint Reference" icon="list" href="/en/developers/reference/endpoints">
    Which endpoints stream
  </Card>

  <Card title="Error Codes" icon="triangle-exclamation" href="/en/developers/error-codes">
    Reading the code inside a `run.failed` payload
  </Card>
</CardGroup>

<Note>
  Verified 2026-08-11. Source: `services/core/src/lib/sse-event-mapper.ts` (`EVENT_MAP` 16 entries + `TOOL_CALL_STATUS_MAP` 4 entries = 20 public event names; that file is the single source of truth for the vocabulary).
</Note>
