# Turn on the dashboard

Add two lines to your config, or run one command with no credentials. The setting, the ports, the container, and the demo.

Source: https://tokentriage.com/docs/build/dashboard-enable

---
import Callout from '../../../components/Callout.astro';
import Details from '../../../components/Details.astro';
import NextSteps from '../../../components/NextSteps.astro';

The dashboard is part of the binary, so turning it on is a setting rather than an install.
This page covers the setting, the two ports it interacts with, the container, and the
no-credentials path if you want to look before you commit.

## See it first, with no configuration

`tokentriage demo` boots the proxy and the dashboard against a synthetic ledger, points the
proxy at built-in mock upstreams, and drives a trickle of requests through the pricing
pipeline so rows arrive while you watch. No API key is involved, and the temp directory is
removed when you stop it.

```console
$ tokentriage demo
demo: temp ledger at D:\tt-tmp\tokentriage-demo-35440665, removed on exit
tokentriage dev · mode: ledger
DEMO — synthetic data (watermarked)
proxy: http://127.0.0.1:4215   (point your app's base_url here)
dashboard: http://127.0.0.1:4216/ui/
ledger: D:\tt-tmp\tokentriage-demo-35440665\decisions.jsonl
next:
  $env:ANTHROPIC_BASE_URL = "http://127.0.0.1:4215"
  tokentriage wrap -- <your app>        (Anthropic clients; no env changes)
  tokentriage doctor                    (check the setup)
```

Three things in that capture are the machine's rather than yours. It came from a source
build, so a release prints its version where this says `dev`. It ran on Windows with a
redirected temp directory, so your paths and your shell's `export` line will differ. And the
ports are the ones this run happened to get.

<Callout type="warning">
`demo` binds ephemeral loopback ports and prints them. They are not 8787 and 9090, and they
change every run. Open the URL your terminal printed; a bookmark from a previous session will
not resolve.
</Callout>

Its ledger is synthetic and every dollar tile carries a diagonal **DEMO DATA** watermark, so
nothing you see there can be mistaken for your own traffic. It writes to a temp directory and
does not touch a ledger you already have.

## Turn it on in a config file

Two lines:

```yaml
dashboard:
  enabled: true            # default: false
```

Then validate and start. Validation decodes the block strictly, so an unknown key under
`dashboard:` is an error rather than a setting that quietly does nothing:

```console
$ tokentriage validate --config tokentriage.yaml
mode:      ledger
listen:    127.0.0.1:8787   admin: 127.0.0.1:9090
upstreams: 1
  - openai       openai    https://api.openai.com
tiers:     1 (cheapest -> most capable)
  0. frontier   -> openai/gpt-4o-mini
rules:     0
OK: configuration is valid
```

The boot banner prints the dashboard URL when the admin listener binds:

```console
$ tokentriage run --config tokentriage.yaml
tokentriage dev · mode: ledger
proxy: http://127.0.0.1:8787   (point your app's base_url here)
dashboard: http://127.0.0.1:9090/ui/
ledger: D:/tt-tmp/ttdocs/state/decisions.jsonl
next:
  $env:ANTHROPIC_BASE_URL = "http://127.0.0.1:8787"
  tokentriage wrap -- <your app>        (Anthropic clients; no env changes)
  tokentriage doctor                    (check the setup)
  tokentriage demo                      (no traffic yet? the whole journey on synthetic data)
```

The ledger path is the one this config named; yours is whatever `ledger.decision_log.path`
says, defaulting to `$HOME/.tokentriage/decisions.jsonl`.

<Callout type="key">
`dashboard.enabled` is `false` in a config file and `true` in the zero-config quickstarts.
`tokentriage run --anthropic` and `run --simple … --complex …` compose the dashboard for you;
pass `--no-dashboard` to opt out. Nothing else turns it on.
</Callout>

## Give it its own index path

The dashboard keeps a SQLite read model derived from your ledger. Its path defaults to
`~/.tokentriage/index/decisions.sqlite`, which is shared by every instance that does not set
one:

```yaml
dashboard:
  enabled: true
  index:
    path: /var/lib/tokentriage/index/decisions.sqlite
```

Set it per instance. Two instances with different ledgers and one default index path will
contend over the same file, and a large or unexpected index at that path can stall a boot
while it is opened and caught up before the listener comes up. If a start hangs after
`replica identity resolved` and before the banner, that is where it is.

The read model is derived and disposable — deleting it costs you nothing but the rebuild.
[The read model](/docs/build/dashboard-read-model) covers when to do that and what `index
rebuild` does.

## The two ports

The dashboard is served from the admin listener, never the data plane.

| | Default | Serves |
|---|---|---|
| Data plane | `127.0.0.1:8787` | the proxy (`server.listen`) |
| Admin plane | `127.0.0.1:9090` | `/ui/`, `/tt/api/v1`, `/health`, `/metrics` (`server.admin`) |

Both default to loopback. That default is what makes `auth.mode: off` valid out of the box:
a non-loopback admin bind with authentication off is a validation error rather than a warning.
Before you move the admin listener off loopback, read [sign in to the
dashboard](/docs/build/dashboard-auth) and Operate's [hardening
guide](/docs/operate/hardening), which owns the network stance for the admin plane.

An admin-plane bind failure is not fatal. The proxy keeps serving and the banner says so:

```console
dashboard unavailable: listen tcp 127.0.0.1:9090: bind: Only one usage of each socket address (protocol/network address/port) is normally permitted. — proxy traffic unaffected (run: tokentriage doctor)
tokentriage dev · mode: ledger
proxy: http://127.0.0.1:8787   (point your app's base_url here)
```

The sentence in the middle is the operating system's, so Linux and macOS say "address
already in use" where that Windows capture says something longer. What matters is the part
after it, and the missing `dashboard:` line below.

A data-plane bind failure, by contrast, stops the process. The asymmetry is intentional: the
proxy is the product, and losing the read plane should not take traffic down with it.

## In a container

Two facts about the published image surprise people, and they compound.

**The baked-in config has no `dashboard:` block, so the image serves no UI.** Port 9090 is
published and answers `/health` and `/metrics`, but `/ui/` is not there until you mount a
config that turns it on. [Install TokenTriage](/docs/get-started/installation) covers the
image itself and the `/data` volume it writes to.

**A container config binds `0.0.0.0`, and that changes which authentication modes are legal.**
A loopback bind inside a container is unreachable from outside it, so the admin listener must
bind a routable address, and an unauthenticated dashboard on one is rejected at validation
rather than warned about.

<Callout type="warning">
`auth.mode: off` is invalid when the admin listener binds a non-loopback address. Mount a
config with `token` or `users` before you start, or the process refuses to boot with a
validation error naming the field. [Sign in to the dashboard](/docs/build/dashboard-auth)
covers both modes.
</Callout>

The repository's Compose profiles do this for you. `deploy/compose/compose.demo.yaml` brings up
a token-authenticated demo dashboard against an in-network mock upstream with no provider key
on the machine, and its config is the worked example of the two facts above. The UI shell and
`/tt/api/v1/capabilities` are served before authentication so the demo label is visible without
a token; reading any data needs one.

Publishing 9090 to a host interface publishes the admin plane. Keep it on an internal network
or behind your own proxy; [production deployment](/docs/operate/deployment) owns the topology
and [hardening](/docs/operate/hardening) owns the network stance.

<Details summary="Everything under `dashboard:` and its default">

Every field below is optional. The values are the ones a two-line block resolves to, read
from `GET /config/running` on a running instance.

| Key | Default | What it does |
|---|---|---|
| `enabled` | `false` | serve `/ui/` and `/tt/api/v1` on the admin listener |
| `auth.mode` | `off` | `off`, `token` or `users` |
| `auth.session_ttl` | `12h` | how long a minted session lasts |
| `state_dir` | `~/.tokentriage/dash` | users, token hashes, saved views, audit log, event ring |
| `index.path` | `~/.tokentriage/index/decisions.sqlite` | the read model |
| `stream.client_queue` | `1024` | per-client bounded queue on the live stream |
| `stream.max_clients` | `32` | live-stream client cap |
| `evidence.max_age` | `168h` | how fresh shadow evidence must be to satisfy the promote gate |
| `query.deadline` | `10s` | per-query time budget |
| `query.row_cap` | `1000` | maximum rows a single query returns |
| `query.max_concurrent` | `2` | concurrent heavy queries |
| `capture.request` | `false` | store request bodies |
| `capture.response` | `false` | store response bodies |
| `capture.ttl` | `720h` | how long captured bodies survive |
| `capture.path` | `~/.tokentriage/content` | where captured bodies live |
| `api_limits.read_per_min` | `600` | management-API read budget per principal |
| `api_limits.write_per_min` | `120` | management-API write budget per principal |
| `docs_public` | `false` | serve the API reference without authentication |
| `demo` | `false` | synthetic watermarked mode; `tokentriage demo` sets it |

`index.enabled` also decodes, and today it does not do what its name says: the read model is
opened whenever the dashboard is composed, and setting it to `false` neither skips the index
nor changes the capability the server advertises. Measured on a `run --config` instance with
`index.enabled: false`, which still created the SQLite file and still reported the read model
as available. Treat the read model as always on when the dashboard is on. This is filed.

</Details>

## What can go wrong

**The banner prints no dashboard line.** Either `dashboard.enabled` is false, or the admin
listener could not bind. Both cases print a reason; `tokentriage doctor` reports the state
without stopping anything.

**Validation rejects `auth.mode: off`.** The admin listener is not on loopback. That is the
container case above, and the fix is to pick an authentication mode rather than to move the
bind back.

**The process starts but `/ui/` shows "UI not built".** The binary was built from source
without the web assets. Release binaries embed them; a source build needs `make ui` before
`go build`.

**A start hangs before the banner.** Look at the index path, as above.

<NextSteps items={[
  {title: "What a new dashboard shows you", href: "/docs/build/dashboard-first-run", description: "You have it running and there is no traffic yet. Here is what each screen says, and what it should not say."},
  {title: "Sign in to the dashboard", href: "/docs/build/dashboard-auth", description: "The three authentication modes, and the one you need before this leaves loopback."},
  {title: "Surface index", href: "/docs/build/dashboard-surfaces", description: "Every screen, the question it answers, and the setting that makes it appear."},
  {title: "Configuration reference", href: "/docs/reference/configuration", description: "The whole schema, with the dashboard block in the context of everything else."}
]} />
