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

# Streaming in the browser

> What browser-direct actually looks like today: the CORS contract as measured, why EventSource can't connect, the scoped-token pattern to copy, and the one thing you must never do.

Frontend teams keep asking the same four questions. Here are the honest answers, as
the API behaves today.

## CORS: open, and deliberate about resume

Every API response carries permissive CORS headers:

```
Access-Control-Allow-Origin: *
Access-Control-Allow-Headers: Authorization, Content-Type, Last-Event-ID, Idempotency-Key, fn-version
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS
Access-Control-Expose-Headers: fn-request-id, fn-version, fn-idempotent-replay, Retry-After
```

So browser-direct calls work from any origin, `Last-Event-ID` is pre-allowed for
stream resume, and the response headers your retry logic needs (`Retry-After`,
`fn-request-id`) are exposed. This posture is a contract, not an accident — a change
would be a dated-version change announced in the [changelog](/changelog).

Open CORS is **not** an invitation to put an API key in a page. It exists so
*scoped, short-lived* credentials (below) can stream directly.

## Why `EventSource` can't connect

The native browser SSE client cannot set request headers, and every credential on
this API is `Authorization: Bearer` — there is no query-param or cookie auth
(`?api_key=…` returns `401 invalid_key`). So `EventSource`, with its free
auto-reconnect and automatic `Last-Event-ID` bookkeeping, cannot reach the stream at
all.

Stream with `fetch` + `ReadableStream` instead: split on blank lines, accumulate
`id:` / `data:` fields, track the last `id:` yourself, and reconnect with a
`Last-Event-ID` header. Pair it with the
[rebuild-then-attach reload recipe](/guides/streaming#reload-recovery-rebuild-then-attach)
and a hard page reload mid-run survives cleanly — the sequence numbers are shared
between the stream and `GET …/events`, and this combination is verified working
browser-direct.

## Never ship a live key to a page

An `fn_live_` key in browser code (bundle, `localStorage`, a prop) is fully
spend-capable: `runs:write` starts real runs against your balance, and open CORS
means any origin that obtains the key can use it from anywhere. Treat `fn_live_`
(and `fn_test_`) exactly like a Stripe secret key: server-side only, always.

## The recommended browser path: a scoped, short-lived token

The pattern to copy is the one the dashboard's own playground uses — a
**playground token**: a scoped bearer (`fnpg_…`) that can create **at most one
session** at or under a stated `max_spend_usd`, then read/stream/stop only that
session, and expires in **10 minutes** (expiry is the revocation). Losing one to the
browser risks a single bounded run, not the account.

Today that token is minted only by the platform dashboard for its playground —
**there is no public API endpoint to mint one yet** (a stated gap; an ephemeral-token
endpoint is the planned fix). Until it ships, the browser architecture is:

1. **Your backend holds the key.** The page never sees `fn_live_…`.
2. **Create server-side, stream through your own route.** The backend POSTs
   `/v1/sessions`, hands the page the session id, and proxies
   `GET …/stream` through a route handler that injects the `Authorization` header —
   \~20 lines in any framework; forward the client's `Last-Event-ID` header through
   for resume.
3. **Reads can be proxied cheaply and freely** — read GETs are unthrottled, so a
   thin pass-through for `/events` and `/result` costs you nothing.

When the ephemeral-token endpoint ships, step 2 collapses to "mint a token
server-side, hand it to the page, stream direct" — the CORS posture above is already
built for it.

## Test keys in the browser

An `fn_test_` key never spends, which makes it tempting for demos — but it still
authenticates your whole account (reads, webhook management). Keep it server-side
too; use the proxy pattern for demos, with the test key behind it.
