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

# Your own surface

> Forward run.events to your own UI over SSE, tie cancellation to the request's own signal, and end the stream on the result.

A long piece of work with a progress view is still `run()`. The `Turn` it hands
back is awaitable and iterable: `run.events` is the live feed, and awaiting the
same object gives you the `TurnResult` at the end.

<Note>
  Vendo's chat UI is not handed over here, deliberately: this lane gives you an
  event stream and a result, and the surface is yours to build. For a chat
  surface with no work at all, [`handler()` plus
  `useVendoChat`](/backend/converse) is one mount; for Vendo's own thread,
  overlay, and generated screens, see
  [Vendo's Full-Stack Agent](/product/quickstart).
</Note>

## The route

```ts app/api/reports/route.ts focus={11,16-19,31} theme={null}
import { THREAD_ID_HEADER } from "@vendoai/agents";
import { support } from "@/lib/agent";
import { auth } from "@/lib/auth";

export async function POST(req: Request) {
  const user = await auth(req);
  if (user === null) return new Response("unauthorized", { status: 401 });

  const { brief } = (await req.json()) as { brief: string };

  const run = support.run(brief, { as: user.id, signal: req.signal });

  const encoder = new TextEncoder();
  const stream = new ReadableStream<Uint8Array>({
    async start(controller) {
      for await (const event of run.events) {
        controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
      }
      const result = await run;
      controller.enqueue(
        encoder.encode(`event: result\ndata: ${JSON.stringify(result)}\n\n`),
      );
      controller.close();
    },
  });

  return new Response(stream, {
    headers: {
      "content-type": "text/event-stream",
      "cache-control": "no-cache",
      [THREAD_ID_HEADER]: run.threadId,
    },
  });
}
```

Three details carry their weight.

* **`run.threadId` is available immediately**, before the first event, so the
  header goes out with the response rather than after it.
* **`signal: req.signal`** ties the run to the connection. The browser closes
  the tab, the request aborts, the run stops.
* **`await run` after the loop** is the result. Iterating the events does not
  consume it; the same object is both.

<Warning>
  `run.events` has one reader. Attaching a second one while the first is
  reading throws, so forward the feed from this route and fan out in your own
  code.
</Warning>

## What comes over the wire

`run.events` yields `RunEvent`s, each tagged by `type`.

| `type`        | Fields                  | What it marks                              |
| ------------- | ----------------------- | ------------------------------------------ |
| `text`        | `delta`                 | The model's own words, as they arrive      |
| `status`      | `label`                 | Where the run is                           |
| `tool-call`   | `id`, `tool`, `args`    | A tool the run is about to use             |
| `tool-result` | `id`, `tool`, `outcome` | What that tool answered                    |
| `error`       | `message`               | The run failed, in the harness's own words |

Forward them whole, as above, and branch in your UI. A new event type then
reaches your client the day it ships, instead of being dropped by a server that
only knew five.

## Ending on the result

The `TurnResult` is the honest close: `status`, the model's own `text`, the
`toolCalls` it made, `threadId`, `turnId`, and `usage`. One `status` decides
which of the four screens you draw.

```ts app/reports-client.ts focus={3-5} theme={null}
source.addEventListener("result", (event) => {
  const result = JSON.parse(event.data);
  if (result.status === "stopped") showCancelled(result.reason);
  else if (result.status === "interrupted") showApprovals(result.interruptions);
  else if (result.status === "error") showFailed(result.error.message);
  else showDone(result.text);
});
```

A run that parked for a human comes back `interrupted`, and the cards are on
`interruptions`. Carrying on is `resume(decisions)` on the server-side object,
not a fresh `run()` — the turn picks up from where it stopped.

<CardGroup cols={2}>
  <Card title="Run" href="/backend/run">
    The result shape, typed output, and the usage you meter on.
  </Card>

  <Card title="Telemetry" href="/production/telemetry">
    The same runs, seen from your Cloud console.
  </Card>
</CardGroup>
