# Show progress in a browser

> Follow a job live from your frontend, with no API token in the browser and no polling.

A browser can follow a job as it runs: its status, its progress and its notes, pushed the moment they change. It needs no API token. Your server asks emit for a **follow token** for one job, and the browser follows that job with it.

A browser following a job sees its status, progress and notes. It never sees the payload, the result, the error, events or log lines.

:::steps
:::step Decide who may follow
Each space has a **Who may follow a job** setting (`publicSubscriptions` in the API):

| Setting | A browser needs |
| --- | --- |
| `signed` (the default) | a follow token from your server, for that job |
| `unsigned` | only the job's id |
| `disabled` | nothing works: only API tokens can follow |

Keep `signed` unless job ids alone are safe to hand out.
:::
:::step Get a follow token on your server
Create the job, then ask for a token that lets its holder follow it, for a second up to a year. This needs an API token with the `read` scope.

:::code-group
```ts TypeScript
// POST /exports, in your API
const { id } = await emit.jobs.create("export-csv", { accountId: "acc_19", month: "2026-09" });
const followToken = await emit.jobs.followToken(id, { expiresIn: "1h" });

return Response.json({ jobId: id, followToken });
```
```go Go
created, err := c.Jobs.Create(ctx, emit.NewJob{Name: "export-csv", Payload: map[string]string{"accountId": "acc_19", "month": "2026-09"}})
if err != nil {
	return err
}
followToken, err := c.Jobs.FollowToken(ctx, created.ID, time.Hour)
if err != nil {
	return err
}
json.NewEncoder(w).Encode(map[string]string{"jobId": created.ID, "followToken": followToken})
```
```bash curl
curl -s -X POST "$EMIT_URL/v1/spaces/exports/jobs/01M3QSGN0103P7EGVBX9D5NTSN:followToken" \
  -H "Authorization: Bearer $EMIT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"expiresInMs": 3600000}'
# {"token":"t=1790729747943,v1=09360a4d2d6c..."}
```
:::

emit signs the token itself, so there's no secret for you to keep. A token only works for its job, on the cluster that holds it.
:::
:::step Follow the job in the browser
With the TypeScript SDK, make a client with `token: null` and pass the follow token to `follow`. Without an SDK, the same stream is a plain `EventSource`.

:::code-group
```ts TypeScript
import { Emit } from "@emit.run/sdk";

const emit = new Emit({ url: "https://<your cluster's endpoint>", token: null, space: "exports" });

const { jobId, followToken } = await fetch("/exports", { method: "POST" }).then((r) => r.json());

for await (const event of emit.jobs.follow(jobId, { token: followToken })) {
  if (event.progress) progressBar.value = event.progress.percent;
  if (event.finished) statusLine.textContent = event.finished.status; // "completed", "failed" or "cancelled"
}
```
```js EventSource
const { jobId, followToken } = await fetch("/exports", { method: "POST" }).then((r) => r.json());

const url = `https://<your cluster's endpoint>/v1/spaces/exports/jobs/${jobId}/events?token=${encodeURIComponent(followToken)}`;
const events = new EventSource(url);

events.onmessage = (message) => {
  const event = JSON.parse(message.data);
  if (event.progress) progressBar.value = event.progress.percent;
  if (event.finished) {
    statusLine.textContent = event.finished.status; // "COMPLETED", "FAILED" or "CANCELLED"
    events.close(); // otherwise the browser reconnects
  }
};
```
:::

The first message is the job as it is now (`now`). After it comes one message per change: `progress`, `retrying` when an attempt failed and another is coming, `lines` for new notes, and `finished` last. The SDK gives statuses in lower case and times as numbers. The raw stream gives what the HTTP API sends: statuses in upper case, and times in milliseconds as strings.
:::
:::

## Following a workflow run

For a workflow run, ask for the whole run as a tree: `emit.jobs.follow(id, { token, tree: true })`, or `&tree=true` on the stream's URL. Each message is then the run with its steps, sleeps and signals, sent again whenever any of them changes. Write `wf.note("Payment confirmed")` in the workflow, or `job.note(...)` in a step, to put a line on the run's timeline for people to read.
