---
title: "Feed access and browser streams"
description: "Authenticate feed subscribers and use Server-Sent Events from browsers."
canonical_url: "https://docs.oz0.ai/build/apps/live-feeds/browser-and-access"
---
# Feed access and browser streams

> Authenticate feed subscribers and use Server-Sent Events from browsers.

For: application developers.

Authenticate feed subscribers and use Server-Sent Events from browsers.

## Who may

Readers of the tenant may open a feed (`OpenFeed` and `UpdateFeed` in the [authorization matrix](https://docs.oz0.ai/concepts/threat-model), and the SSE routes above); nodes, servers, provisioners, other tenants and chat channels' people may not. A feed belongs to whoever opened it: another person, another app's key or an operator who sends its token gets `not_found`, as for a feed that never was. Access is checked when a subscription is added, and every event goes out under the subscription's last check. A share taken back ends the subscription as `revoked` at once, and takes the chat off the former grantee's `my_activity`.

So does every other change that can take access away: a membership lowered or removed, the content permission taken away, a team left, an account disabled or removed, an app's key revoked, an operator disabled. The change rings the feeds' doorbell (or, for a key or an operator, reaches every edge with the registry), and the edge that streams checks the caller again at once: measured on two servers, the stream on one ended 15 to 134 ms after the change on the other (most under 55 ms; a key revoked goes through the registry).

The same holds for `WatchJob` and the SSE streams below, which end with `revoked` (or `permission_denied` for `WatchJob`). The 10-second check stays as a safety net for a bell that is lost. A resume names the jobs the client was reading; the feed follows only those that belong to the subscription (the job or one below it, a turn of the conversation) and that exist in the tenant, which it looks up for all of them at once before it follows any, and ignores any other.

A `WatchJob` resume token is read the same way: a job it names that does not exist is dropped from the token. When those jobs cannot be looked up, `WatchJob` answers `unavailable` and a feed's subscription resyncs. A subscription whose access cannot be decided for a moment (the database or Temporal away) is not revoked: it shows nothing and is decided again until it can be. When how a job ended cannot be read, the feed sends no end state for it and tells the client to fetch the job (`resync`).

### What a kill or a revocation does to streams

A [kill switch or a revocation](https://docs.oz0.ai/govern/kill-switches#exactly-the-jobs-that-lost-their-authority) ends no stream by itself; what it stops does:

- A stream that follows a job the revocation cancels (`WatchJob`, a job's SSE stream, a feed's subscription) gets the job's final event, `finished` with its cancelled state, and the job's stream ends; a feed marks the job done and goes on with its other subscriptions.
- A stream that follows a job a switch parks stays open and quiet until the job resumes or is cancelled.
- A stream opened by a person or an app's key whose access is revoked ends with `revoked`, as above.

The [receipt](https://docs.oz0.ai/govern/kill-switches#receipts) lists the streams of the jobs it stopped as a carrier. A feed subscribed to a job ID that runs again, once its earlier run ended, reads the new run's events, never the earlier run's end.

What a feed shows is never about other people's activity: `my_activity` is your own list, and there are no statistics per person.

## Server-Sent Events

For a browser's `EventSource`, or any HTTP client, a job's events and a feed's come as [Server-Sent Events](https://html.spec.whatwg.org/multipage/server-sent-events.html) too, on the edge and through management:

<table>
<thead>
  <tr>
    <th>
      Route
    </th>
    
    <th>
      Streams
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        GET /v1/jobs/{id}/events
      </code>
    </td>
    
    <td>
      What <code>
        WatchJob
      </code>
      
       streams: the job, the jobs below it, then its final state. Without the content permission, no text and no step details, as for <code>
        WatchJob
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        POST /v1/feeds
      </code>
    </td>
    
    <td>
      Makes a feed with its first subscriptions, as <code>
        OpenFeed
      </code>
      
       does: JSON <code>
        {"subscribe": [{"job": "..."}, {"conversation": "..."}, {"myActivity": true}]}
      </code>
      
      . It answers the feed's <code>
        id
      </code>
      
      , its <code>
        token
      </code>
      
       (a secret, for <code>
        UpdateFeed
      </code>
      
      ) and the path of its events
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        GET /v1/feeds/{id}/events
      </code>
    </td>
    
    <td>
      The feed's events. The path names the feed by its ID, never by its token. <code>
        UpdateFeed
      </code>
      
       adds and removes subscriptions as before
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        POST /v1/stream-tickets
      </code>
    </td>
    
    <td>
      A one-time ticket for one of the streams above (below)
    </td>
  </tr>
</tbody>
</table>

Each event has an `event:` name, its kind (`text`, `tool_call`, `finished`, `resync`, `revoked`, ...; for a feed `opened`, `subscribed`, `job`, `text`, `state`, ...), its `data:` as the event's protobuf JSON on one line, and an `id:`. Heartbeats are comments (`: hb`) every 25 s. A client that reconnects sends the last `id:` it had as `Last-Event-ID` (an `EventSource` does this by itself), or as `?last_event_id=` when it opens a new one, and gets exactly what came after, on any edge, or a `resync` where it cannot: the rules of [Resume](https://docs.oz0.ai/build/apps/live-feeds#resume).

A job's ID holds its seq and `WatchJob`'s resume token; a feed's holds its seq and the positions you have of each job, so it can be a few KB for a feed that follows many jobs, and past 6 KB the oldest ended jobs are left out and a resume from it resyncs the feed. A job's stream ends after `finished`, or `revoked`; a client that stops reading for 30 s is dropped.

### Stream tickets

An `EventSource` cannot send an `Authorization` header, and an API key never goes in a URL: one there ends up in logs, proxies and browser histories, so the edge refuses any request that carries one in its query. Instead, where the key lives (your backend), ask for a ticket and hand the browser only that:

```bash
curl -s -H "Authorization: Bearer $OZ0_API_KEY" -H 'Content-Type: application/json' \
  -d '{"job": "job-2f3c"}' "https://edge.example.com:7443/v1/stream-tickets?tenant=acme"
# {"ticket":"oz0st_...","expires_in":60,"url":"/v1/jobs/job-2f3c/events?tenant=acme&ticket=oz0st_..."}
```

```js
const es = new EventSource("https://edge.example.com:7443" + ticket.url);
es.addEventListener("text", (e) => append(JSON.parse(e.data).text));
es.addEventListener("finished", () => es.close());
```

A ticket is 192 random bits, valid for 60 seconds, for the tenant and the one job or feed it names, and for its caller: the stream runs as whoever asked for it, checked again when it opens and while it runs. It opens one stream once, on whichever edge it reaches first: a ticket replayed from a log, a proxy or a browser history is refused, as are one for another job, another tenant, or one sent beside another caller's credentials.

Because it is used up, a browser's own reconnect is refused; ask for a new ticket and open the stream again with `?last_event_id=`. The TypeScript SDK's `eventStream` does this ([TypeScript SDK](https://docs.oz0.ai/build/apps/sdk-typescript)). Asking needs a session, an app token, a tenant runtime API key or an operator's certificate, as JSON (a form is refused), and a caller holds at most 50 unused tickets. Tickets are kept under their SHA-256 and never logged.

A ticket is signed by the cluster, so a made-up one is refused before it costs a database write, and no client is ever held back because of what others behind the same address send.

The console's own pages may open these streams with their session instead, on management's origin.

A backend can follow a stream for one of its people the same way, with a ticket its person's app token asks for: the stream then runs as that person and outlives the token. An app that wants AG-UI events instead follows the [AG-UI route](https://docs.oz0.ai/build/apps/ag-ui), as the [chat app example](https://docs.oz0.ai/build/apps/chat-app) does.

### Other origins (CORS)

By default no page on another origin may read these streams: the edge refuses a request whose `Origin` is not the server's own, and answers no CORS headers. A tenant's admins can allow exact origins:

```bash
oz0 tenant cors acme --allow-origin https://support.acme.example --reason "the support portal shows job progress"
oz0 tenant cors acme --clear
```

The SSE routes, `POST /v1/feeds`, the ticket route and the [AG-UI route](https://docs.oz0.ai/build/apps/ag-ui) answer CORS for those origins (with `?tenant=` in the URL, for the preflight). For feed and ticket creation, the origin must be allowed by the tenant the ticket or feed is for, and a tenant in the body must match the one in the URL.

`Access-Control-Allow-Credentials` is never sent, so such a page reads with an explicit credential or a ticket, never a person's session, and a session that arrives from another origin anyway is refused. Keep API keys and app tokens in your backend; hand the browser stream tickets.

Tenant admins can also manage these origins in the console's **Access › API keys** tab, or through the admin API's `SetTenantCORS`. The console requires governance permission to save them.

Allowing an origin widens who can read the tenant's streams, so it takes a reason and is audited (`tenant.cors`). Where the tenant has [four eyes](https://docs.oz0.ai/govern/reviewed-changes#four-eyes-for-governance-changes) enabled, adding an origin waits for a second admin. Removing origins does not require a reason through the CLI or API. Edges apply an approved change within seconds.

## On the wire

The console speaks binary protobuf on its feed. An idle feed costs little: with 50 subscriptions it sends about 100 bytes a minute, heartbeats, TLS and HTTP/2 framing included. Clients that ask for Connect's own per-message compression (the Go and Python SDKs do) get it. A feed's stream is never compressed across its events, so what someone can put into your feed shares no compression with the rest of it.

## Across edges

A feed's state is a row in the database (its holder, its lease, its subscriptions), never what it carries: the edge that holds a feed reads each job from Temporal itself, with one poll per job shared by every feed and watcher on that edge. An `UpdateFeed` that lands on another edge rings the feed's doorbell: the edge that holds it hears it from PostgreSQL's `LISTEN`, or from management's watch stream when edges run apart, and takes the new subscription. Nothing is added to the cluster: no message bus, no port, no route between edges.


## Sitemap

See the full [sitemap](https://docs.oz0.ai/sitemap.md) for all pages.
