Feed access and browser streams
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, 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 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,finishedwith 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 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 too, on the edge and through management:
| Route | Streams |
|---|---|
GET /v1/jobs/{id}/events | What WatchJob streams: the job, the jobs below it, then its final state. Without the content permission, no text and no step details, as for WatchJob |
POST /v1/feeds | Makes a feed with its first subscriptions, as OpenFeed does: JSON {"subscribe": [{"job": "..."}, {"conversation": "..."}, {"myActivity": true}]}. It answers the feed's id, its token (a secret, for UpdateFeed) and the path of its events |
GET /v1/feeds/{id}/events | The feed's events. The path names the feed by its ID, never by its token. UpdateFeed adds and removes subscriptions as before |
POST /v1/stream-tickets | A one-time ticket for one of the streams above (below) |
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.
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:
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_..."}
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). 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, as the chat app example 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:
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 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 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.