# Follow a run from the browser

> Let a web page show a run's progress with a short-lived, read-only token, without your API token.

This guide lets a web page show a run's progress straight from Kindgi, with a short-lived, read-only token instead of your API token.

Your backend starts the run and hands the page two things: the run's id and a **public run token**. The page follows the run's progress with them, straight from Kindgi. The token can do nothing else, and it expires.

## Before you start

- **A flow to start,** registered on your runtime. This guide starts `acme.reserve-order`; use your own flow's id.
- **A backend that starts runs** with Kindgi 0.1's client, on Node 22.12 or later, Python 3.11 or later, or Java 17 or later, as [Start a run](https://docs.kindgi.com/v0.1.9/guides/runs/start-a-run.md) shows.
- **A web page that imports npm packages,** such as one a bundler like Vite builds, for `@kindgi/sdk/client`.
- **`kindgi dev` running** in your pack's directory.

## 1. Allow your site's origin

The browser calls Kindgi from your site's origin, so Kindgi must allow it.

List the origins in `KINDGI_CORS_ORIGINS`, comma-separated. In development, add it to the pack's `.env`:

```sh
echo 'KINDGI_CORS_ORIGINS=http://localhost:5173' >> .env
```

`http://localhost:5173` is your page's origin: its scheme, host and port.

Restart `kindgi dev`. Its banner shows the origin:

```text
    Origins    http://localhost:5173 (browsers may follow runs with public run tokens)
```

In a deployment, set it in the runtime's environment, as [`KINDGI_CORS_ORIGINS`](https://docs.kindgi.com/v0.1.9/reference/env-vars.md#kindgi_cors_origins) describes.

## 2. Hand the page a token

The page needs a token for the run it shows, and a fresh one when that token expires.

When the deployment issues public run tokens, every start returns `publicAccessToken`: a token for the run it started, valid for 15 minutes by default. `kindgi dev` issues them. A deployment issues them when it has a signing key, [`KINDGI_PUBLIC_TOKEN_SIGNING_KEY_PATH`](https://docs.kindgi.com/v0.1.9/reference/env-vars.md#kindgi_public_token_signing_key_path).

In your backend, pass the token on with the run's id when a run starts, and mint a fresh one when the page asks. Ask your coding agent: "When my backend starts a run, I want it to give my web page a token to follow it, and a fresh one when that expires." For reference, the code looks like this:

- **TypeScript**

  `app/reservations.ts`

  ```ts
  import { createClient } from '@kindgi/sdk/client';
  import type { RunId } from '@kindgi/sdk/types';

  const kindgi = createClient(); // KINDGI_API_URL, KINDGI_API_TOKEN

  /** When the user starts a reservation. */
  export async function startReservation(orderId: string) {
    const run = await kindgi.runs.start({
      flow: 'acme.reserve-order',
      input: { orderId },
      options: { wait: false },
    });
    return { runId: run.id, token: run.publicAccessToken };
  }

  /** When the page asks for a fresh token. Check first that the user may see this run. */
  export async function freshToken(runId: RunId) {
    const { token } = await kindgi.tokens.createPublic({ runIds: [runId] });
    return token;
  }
  ```

- **Python**

  `app/reservations.py`

  ```python
  from kindgi.client import Kindgi

  kindgi = Kindgi()  # KINDGI_API_URL, KINDGI_API_TOKEN


  def start_reservation(order_id: str) -> dict[str, str | None]:
      """When the user starts a reservation."""
      run = kindgi.runs.start(
          flow="acme.reserve-order", input={"orderId": order_id}, options={"wait": False}
      )
      return {"runId": str(run.id), "token": run.public_access_token}


  def fresh_token(run_id: str) -> str:
      """When the page asks for a fresh token. Check first that the user may see this run."""
      return kindgi.tokens.mint_public(run_ids=[run_id]).token
  ```

- **Java**

  `src/main/java/com/acme/app/RunTokens.java`

  ```java
  package com.acme.app;

  import com.kindgi.client.Kindgi;
  import com.kindgi.client.models.MintPublicRunTokenBody;
  import com.kindgi.client.models.Run;
  import com.kindgi.client.models.StartRunBody;
  import java.util.List;
  import java.util.Map;
  import java.util.UUID;

  public final class RunTokens {
    private static final Kindgi kindgi = Kindgi.create(); // KINDGI_API_URL, KINDGI_API_TOKEN

    public record Started(UUID runId, String token) {}

    /** When the user starts a reservation. */
    public static Started startReservation(String orderId) {
      Run run = kindgi.runs().start(StartRunBody.WithFlow.builder()
          .flow("acme.reserve-order")
          .input(Map.of("orderId", orderId))
          .options(StartRunBody.WithFlow.Options.builder().wait_(false).build())
          .build());
      return new Started(run.id(), run.publicAccessToken());
    }

    /** When the page asks for a fresh token (check first that the user may see this run). */
    public static String freshToken(UUID runId) {
      return kindgi.tokens().mintPublic(MintPublicRunTokenBody.builder()
          .runIds(List.of(runId.toString()))
          .build()).token();
    }

    private RunTokens() {}
  }
  ```

- **Scala**

  `src/main/scala/acme/app/RunTokens.scala`

  ```scala
  package acme.app

  import com.kindgi.client.Kindgi
  import com.kindgi.client.models.{MintPublicRunTokenBody, StartRunBody}
  import java.util.UUID
  import scala.jdk.CollectionConverters._

  object RunTokens {
    private val kindgi = Kindgi.create() // KINDGI_API_URL, KINDGI_API_TOKEN

    final case class Started(runId: UUID, token: String)

    /** When the user starts a reservation. */
    def startReservation(orderId: String): Started = {
      val run = kindgi.runs().start(StartRunBody.WithFlow.builder()
        .flow("acme.reserve-order")
        .input(Map("orderId" -> orderId).asJava)
        .options(StartRunBody.WithFlow.Options.builder().wait_(false).build())
        .build())
      Started(run.id, run.publicAccessToken)
    }

    /** When the page asks for a fresh token (check first that the user may see this run). */
    def freshToken(runId: UUID): String =
      kindgi.tokens().mintPublic(MintPublicRunTokenBody.builder()
        .runIds(List(runId.toString).asJava)
        .build()).token
  }
  ```

Serve each from a route of your backend. This guide calls them `POST /api/reservations` and `GET /api/run-token?runId=<run-id>`.

The page now gets a run's id and its token, and can ask for a fresh token. A minted token can name up to 50 runs. It lives from a second to a day, as `expiresInSeconds` sets, or `expires_in_seconds` in Python.

## 3. Follow the run in the page

The page shows each step as the run takes it.

Ask your coding agent: "I want my web page to show the run's progress as it happens." For reference, the page's code looks like this. It uses `subscribeToRun` from `@kindgi/sdk/client`, which runs in the browser:

`web/progress.ts`

```ts
import { subscribeToRun } from '@kindgi/sdk/client';

const progress = document.querySelector('#progress');

/** The page's own code: one line per event. */
function showProgress(kind: string, nodeId?: string) {
  progress?.append(nodeId === undefined ? `${kind}\n` : `${kind} ${nodeId}\n`);
}

const { runId, token } = await fetch('/api/reservations', { method: 'POST' }).then((r) => r.json());

for await (const event of subscribeToRun({
  apiUrl: 'http://127.0.0.1:4313', // your Kindgi API
  runId,
  accessToken: token,
  refreshAccessToken: () => fetch(`/api/run-token?runId=${runId}`).then((r) => r.text()),
})) {
  showProgress(event.kind, event.nodeId); // run.step-started reserve, …
}
// The loop ends after run.completed, run.failed or run.cancelled.
```

- `apiUrl` is your Kindgi API's URL: `kindgi dev`'s here.
- `accessToken` is the token your backend handed the page.
- `refreshAccessToken` asks your backend for a fresh token.

In a page served from `http://localhost:5173`, for a run of `acme.reserve-order`, `showProgress` gets:

```text
run.started
run.step-started reserve
run.step-retry-scheduled reserve
run.step-retry-scheduled reserve
run.step-completed reserve
run.completed
```

`subscribeToRun` reconnects by itself when the server ends the stream. It calls `refreshAccessToken` when the token expires.

## What the token can do

- **Only follow progress.** The token works on two routes, `GET /v1/runs/{runId}/progress` and `GET /v1/runs/{runId}/progress/stream`. It works for the runs it names and the runs inside them, such as an agent step's turn. Any other call is refused:

  ```text
  {"error":{"code":"permission-denied","message":"A public run token can only follow the runs it names: GET /v1/runs/{runId}/progress and GET /v1/runs/{runId}/progress/stream","requestId":"req-19ffb614-4c27-438b-95dd-5ace0de7125b"}}
  ```

- **No data.** Progress events have the kind, the step and the time, but no inputs, outputs or errors:

  ```text
  id: f7bb2e8c-6895-4ae4-a377-065be3a6a952:2
  event: run.step-started
  data: {"eventId":"f7bb2e8c-6895-4ae4-a377-065be3a6a952:2","runId":"f7bb2e8c-6895-4ae4-a377-065be3a6a952","timestamp":"2026-10-03T20:13:13.452Z","kind":"run.step-started","sequence":2,"nodeId":"reserve"}
  ```

  Show the result from your backend, which reads the run with your API token.

- **No revoking one by one.** Keep lifetimes short. In development, tokens are signed with a key `kindgi dev` makes when it starts, so they stop working when it restarts.

## Next

- [Show runs in your app](https://docs.kindgi.com/v0.1.9/guides/runs/show-runs-in-your-app.md)
- [Follow a run](https://docs.kindgi.com/v0.1.9/guides/runs/follow-a-run.md)
- [Security: following a run from a browser](https://docs.kindgi.com/v0.1.9/concepts/security.md#following-a-run-from-a-browser)
