Web interface
A browser client of the read API, served by the same binary on the same port.
It has no endpoints and no logic of its own: everything it shows is reachable
with curl, and a feature that is not in api.md does not exist.
Opening it
The interface is at the server's root — http://localhost:4318/ on the
default listen address.
It always authenticates. There is no localhost bypass: behind any local reverse proxy every request looks like loopback, which would silently turn authentication off for the internet.
A person signs in with an email and a password — an account, not a project key. A key is what a program holds; the interface stops being a place to paste one.
- First run. The server has no owner yet, so it prints a setup link
next to the connection strings (
http://localhost:4318/setup#token=…) and every screen redirects to/setupuntil somebody uses it. The token travels in the URL fragment, which browsers never send to a server; the screen reads it and takes it back out of the address bar. It is minted afresh at every start while nobody can sign in, so a link from yesterday's log opens nothing — and a restart is the recovery if it was lost. - An invitation. An owner creates an account and is handed a link
(
/invite#token=…) once, to carry to the person themselves; the link sets their password and signs them in. The same link, minted again, is how a lost password is replaced. - Later.
/loginasks for the email and the password. Every way of failing — wrong email, wrong password, disabled, invited and never accepted — is one refusal with one sentence, because any difference between them is a way to find out who has an account here.
The session is an HttpOnly cookie the browser holds and no script can read.
It lasts about a month and renews itself as you use it; a 401 on any
request returns to the login screen with where you were. Nothing about the
credential is kept in localStorage. Signing out ends the session on the
server, and Account → Where you are signed in ends the others.
The admin token is not a credential this interface takes at all: an owner reaches everything it used to unlock (see admin.md).
The project in the address
Every screen is about one project, and the address says which: everything
inside the shell lives under /p/{project id} — /p/{id}/dashboard,
/p/{id}/traces, /p/{id}/settings/server, and so on. A link therefore
names its project, which is what a link shared between two people has needed
since there were two people; it carries the id rather than the name, because a
name can be renamed and a link that breaks on a rename is worse than a hex
segment. /login, /setup and /invite are the three screens outside the
shell and keep their paths. One screen inside the shell is about the person
rather than a project and lives bare: the Account tab, at /settings/account
— it is yours whether or not you reach a project.
A bare path — /, or /traces?status=error from a bookmark, a chat, or
the pre-authed link the server prints — redirects to the same path and query
under the remembered project: the one this account last looked at in this
browser, or the first by name until it has looked at one. Every link written
before the prefix keeps working. / and /p/{id} open the project's
dashboard; so does the login form with nowhere else to return
to, and so do the setup and invitation links once the password is set. An
account that reaches no project is sent to /p, which says so — and, for an
owner, offers to create one.
The switcher at the top of the sidebar, where the project name is, lists the projects the account can reach with each one's traces of the last 24 hours beside it — is it alive — and, over eight projects, a box that narrows the list. Choosing another project keeps the section you are in (and the Settings tab) and keeps the filters, the time window, the search and the page size; what it drops is what belonged to the old project: the open trace, prompt or queue, the page cursor and the peek panel; from a screen that is under no project it lands on the dashboard. An owner has New project at the bottom of the menu, which opens the same dialog the Server tab uses and lands on the new project's dashboard, where the setup instructions are.
An address under a project the account cannot reach — mistyped, deleted, or one you are not a member of — renders one screen saying so, with the switcher open beside it, and sends no request for it; the interface does not say which of the three it is, because the server does not either.
What a role sees
The sidebar carries an account menu at the bottom: the display name or the email, the role in the project on screen as a caption, and the two things the menu is for — the Account tab and signing out.
Every screen is reachable for every role, and the dashboard — its Customize
mode included, since the arrangement is the account's — the listings, the
trace detail, sessions, quality, users and the whole annotation flow are
identical.
Scoring is a viewer's job — that is what the role is for. What a viewer is
not offered is the writing: the prompt editor and its labels, the dataset
item editor and the archive, deleting a prompt, a dataset, a queue, a run or
a trace, the score configs' create and edit, and Add to queue — filling a
review programme is queue management, and queue management is an editor's.
Hiding is for the person; refusing is for the security. The server answers
every one of those 403 regardless (see accounts.md), and a
403 that reaches the interface anyway — a role changed under an open tab —
is rendered in the server's own words in the card or dialog it came from.
Screens
Dashboard — the project's front page, first in the sidebar and what
/p/{id} opens: how much the project ran, what it cost, whether it broke and
whether the scores moved, at a glance. See Dashboard.
Traces — the listing. One row per trace, mapping onto
GET /api/v1/traces: time, name, environment, user, session, cost, tokens,
latency, TTFT and how many observations failed. TTFT sits beside latency because the
two answer the same question from opposite ends — how long the whole run took,
and how long somebody waited before anything appeared. The filter bar offers
exactly the filters the endpoint accepts (q, from, to, environment,
user_id, session_id, name, tag, status, min_cost, min_tokens,
release, version, type, prompt) — a test reads openapi.json and fails
if the two ever disagree. The bar underneath turns the pages. Min tokens
is input plus output tokens, a whole number: a fraction typed into it, or
carried by a hand-edited link, is dropped rather than sent
(api.md). The Tokens column shows the same number — input
plus output, compact (950, 12.4k, 3.1M) — and a dash where the trace
carried neither; its tooltip lists every class the trace reported, exact, so
reasoning and cache tokens are read there and are never added to the figure.
The Sessions and Users tables have the same column
over their own sums, and fold it under the id on a narrow screen with the cost.
Three of them — environment, release and name — are checkbox lists
rather than boxes to type in. The values come from
GET /api/v1/facets for the window in view, read when
the panel opens and again when the window moves under it, and each carries how
many traces of that window have it: prod 1 beside production 4656 is a
typo, and nothing but the count says so. Ticking two means either, which
travels as the comma form the API takes (?environment=production,staging).
Above eight values the list gets a box that narrows it by substring, and past a
hundred it says how many it left out. A value that arrived in a link and is not
in the list — ?environment=canary, from before canary was retired — is shown
checked at the top without a count, because a filter that cannot be seen cannot
be undone.
A chip names up to two values in full and counts beyond that (Environment: 3 values), with all of them in its tooltip: the chip row is read at a glance, and a glance holds two names.
There is no release column: release is a filter, and it is shown in the header of a trace rather than in every row of the listing.
Search — the box on the bar, beside the time window. It searches what the observations carried, not their labels: prompts, answers, metadata, names and error messages. It commits on Enter and on leaving the box, never on a keystroke, and it lives in the URL like every other filter.
A row that matched grows a second line under it: which observation and field
the hit was in, and the text around it, with the words of the query marked.
Clicking such a row opens the panel on that observation rather than at the
top of the trace — which is the whole reason the row says where it matched.
The matching rules are the API's, and worth knowing at the box: words rather
than substrings (err* for a prefix), "quoted words" must be adjacent, and
all the words have to occur in the same field of the same observation. See
api.md.
Live re-reads the newest page every five seconds and shows it. It is off
by default, pauses while the tab is hidden, and only runs on the newest page
(see Turning pages). Same caveat as tracepad tail: traces
are ordered by their own timestamps, so a span that arrives late appears
where it belongs rather than at the top — which on a full page means it can
push the oldest row of that page off it.
When a project has no traces at all the listing says so in one line and points at the dashboard, where the exporter settings are; with a filter or a search set it says what matched nothing instead.
Sessions — one row per session over GET /api/v1/sessions: last seen,
id, how many traces, how many of them failed, cost, tokens, first seen. The filters
are the endpoint's four (from, to, environment, user_id) — the
environment as the same checkbox list the Traces panel offers, and the user id
as a box, because a user id is not a short finite set — and a row
opens the session: its totals over its traces, and a trace opens from there.
Every number counts traces, which is what a session is a collection of.
The session id in the Traces table, in a trace's peek panel and in a trace's header is a link here, after the row's own link and the user id. Inside a session — its page, its panel — it stays plain text: it names the session being read.
Scores
A judgement about a trace, an observation or a session is shown where its
target is read, and can be written from there. It is
scores.md on screen: every chip came out of
GET /api/v1/scores, and Score is one POST — the same write an eval
harness makes.
The block — a Scores row on the trace header (wherever a trace is read: the page, the peek panel, a session's drill-down, a run item's), on the observation panel between the payloads and the metadata, and on the session header. One chip per score: the name, the value, where it came from and how long ago, newest first. Expanding a chip shows the comment — and the whole of a long text score — with Edit and Delete under it.
The value is rendered by its type: a number to three significant digits with
the config's range as its tooltip, a boolean as yes or no, a category as
itself, a text score cut at 120 characters. The source is metadata.source
when the writer set one — web for this screen, whatever the SDK or the
widget wrote otherwise — and api when it did not.
Beside the source, the author: by Ada for an account, key nightly-judge for a program's key. The source says what kind of judgement it is, the author whose. Its tooltip is the account's email for an editor or an owner; a viewer reads the name alone, and an account that never set one is a member. An author who is gone — removed from the project, disabled, deleted, a revoked key — is muted, with the reason in the tooltip: the judgement stands. A score from before the server recorded authors shows none.
The trace header carries the scores of the trace; a score that names an observation belongs to that observation's panel, and the header says how many went there. That count is what opening the panels will find, so it can be acted on. The tree badges each observation with how many it has, so "which step was graded" is answered before any panel is opened. A score naming an observation this trace does not carry — a late span, a wrong id — is shown in the header marked unknown observation, because no panel would ever show it, and it is not in the count for the same reason.
Score opens a dialog. The name is a select over the project's score
configs, and the control under it is the one the
config dictates: a number input bounded by its min/max, two buttons for a
boolean, a select over the categories, a text area. other… takes a free name
and asks what kind of value it carries — a project that declared no configs
must still be able to score. Saving posts one score stamped
metadata: {"source": "web"} and no timestamp, so it is received now.
Edit is the same dialog over an existing score, re-posted with its id:
the score API's correction, which replaces the row rather than adding a second one.
It is offered on every score, not only the ones written here — a judge's
verdict overruled by a person is the review the eval loop exists for. What the
dialog does not show, it resends as it was: the score's metadata, its
timestamp, and what it is about — so an edit invents neither a new author nor
a new event time, and it never moves a score off the observation or the session
it grades.
Delete asks once, naming the score and its value, and then calls
DELETE /api/v1/scores/{id}. There is no name to type back: a score is one
row that writing the same id again puts straight back.
Prompts
Prompts is a top-level section, between Users and Evals: a prompt is a production artefact — what the application ships — rather than an eval noun. It is prompts.md on screen, and it writes through exactly the endpoints described there.
Prompts — one row per name over GET /api/v1/prompts: name, type, the
latest version, the labels as chips with the version each points at
(production first, then alphabetical), and when the name last gained a
version. A row opens the prompt. An empty listing shows the push that fills
it and the SDK line that reads it back.
Prompt — the versions down the left, the version the URL names on the
right. The header carries the name, the type, where each label points, and
New version, Diff and Delete. ?version=V picks a version — absent
means the latest — and the version list is the shared listing, so a name with
hundreds of versions pages like everything else. The view is the body as it
is: role-labelled blocks for a chat prompt, one block for a text one — prose,
not an escaped JSON string — with config as a document, and two links that
answer where it ran: Traces with v7 and Traces with any version, which are
the prompt= filter the traces listing takes. A ?version= the name
does not have says so, with the version list still beside it.
Labels — the control lives on the version being read: the labels on it as chips with a ×, and Add label…, which offers the name's other labels or takes a new one. Attaching a label that points nowhere is immediate — a new label is a note. Moving one that points at another version, and removing one, ask first, and the question names the move: production: v6 → v7, which is what a rollback reads before confirming. A move writes no version: promoting and rolling back are the same one-line operation the API describes.
Diff — ?diff=A..B replaces the version view with the patch
GET /api/v1/prompts/{name}/diff returns, painted per line: additions,
removals, hunk headers. The interface computes no diff, so the screen, the CLI
and an agent all read the same one. The two selects are number inputs bounded
by the latest version, not menus of every version. ?diff=3..3 says
identical; a name with one version says there is no diff yet.
Writing a version — New prompt takes a name and a type; New version
opens a copy of the version on screen, because an edit is a new version. The
bodies are plain text areas — a prompt has no syntax to lint — one for a text
prompt, one per message for a chat one, each with a role beside it and add,
remove, up, down. The role is a menu of the roles the runtimes name —
system, user, assistant, developer, tool, model — plus Custom…,
which opens a field for any other string, because the API stores whatever role
you give it; a message that arrives with a role outside the menu opens in that
field. An added message alternates: after a user an assistant, after
anything else a user. Nothing requires a system message — a body that is
one user turn is a prompt like any other. config is the same JSON surface
every payload uses. Save posts one version and lands on it. The gate is the
server's own rules mirrored at the fields — the name's grammar, a non-empty
body, a role and content per message, a config that parses, and latest
refused as a label — so that a 400 naming
one of them is not how you find out; the server still decides.
Delete takes the name whole: every version and every label, in one transaction. It wears the ceremony every act with a blast radius wears here — the server's dry run on screen, with what it would take and what it would not, and the name typed back. Traces that ran the prompt keep the name and version they recorded, so the trace filter goes on answering for it.
Evals
The sidebar's Evals section is the eval nouns of datasets.md on screen — Datasets, Runs and Score configs — with the review queues of annotation.md beside them (Queues) and what they all produce, over time, at the end (Quality). Every table is the shared listing (pages in the URL, the bar underneath), every row opens in the peek panel, and every payload is the same surface as a trace's. Nothing is computed here that the API did not send: a mean, a verdict, a delta are the server's, so the screen, the CLI and the MCP tools cannot disagree about whether a change made it better.
Datasets — one row per dataset over GET /api/v1/datasets: name,
description, version, items, runs, updated. A row opens the dataset. New
dataset takes a name and a sentence and nothing else — that is the whole
envelope — and lands on the empty dataset; a name the project already has is
a link, not a silent replacement of its description.
Dataset — the envelope in the header, and two tabs in the URL
(?tab=items, ?tab=runs). Items lists the cases at the version in force,
oldest first in first-appearance order: position, id, the first characters of
the input and the expected output as compact JSON, and the version the row
was written at. The version control in the header is a number, not a
menu — a dataset edited in CI has hundreds — and rewrites the tab to
?version=V; an older version is read-only and says so, because an edit is
a new version at the head. A row opens the item whole: the three bodies as
documents, where the case was cut from (a link, offered rather than promised —
the trace may be gone), and every row of the item's history, each linking to
the dataset at that version. An item reached from an older version that has
since been archived says so. Runs is the dataset's runs, newest first, with
the checkboxes below.
Editing a case — New item in the dataset header, Edit in an item's panel, or Add to dataset on any observation (below) all land on the same page: three editors — Input, Expected output, Metadata — over the payload surface with the parse error in place. Save opens once the input is a document and neither other pane is a broken one; an empty pane is a field left out, not a syntax error. What a save reports is the store's own answer: saved as version V, or unchanged when the write changed nothing, because a write that changes nothing writes nothing and only the server can say so. Saving again edits the same item — an id re-posted is an edit — and every earlier version keeps the row it had. Writes land at the head, so New item, Edit and Archive are offered only while the head is the version on screen; Archive asks once and removes the case from the current version while leaving it readable at every earlier one. Delete on the dataset is the one act here with a blast radius, and wears the same ceremony as the rest of the interface: the server's dry run on screen — items, runs, and the traces those runs were pinning, which are released rather than deleted — and the dataset's name typed back.
Runs — every dataset's runs over GET /api/v1/runs, newest first, with
the two filters the endpoint takes (dataset, status) and the capped count.
There is no coverage column: a page of runs is for choosing one, and the
listing's rows carry no summary. Two checkboxes and Compare are the
second way into a comparison: the button is disabled — with the reason as its
tooltip — unless exactly two runs of one dataset are ticked, so the mistake
the server would refuse stays a tooltip rather than becoming a page. Runs are
not created here: a run is opened by the harness that will stamp its traces,
and the empty listing shows the request that opens one.
Run — the header names the run, links dataset@version to the items tab
at that version, shows the status the harness said (and, beside running,
how long it has been open), and offers Compare with…, a select of the
dataset's other runs, and Delete — which asks once and names what it does:
the run's bookkeeping goes, and the traces it was keeping stop being pinned
and live as long as retention says. The cards are the summary GET /api/v1/runs/{id}
computes: coverage (items, covered, missing, unknown traces), traffic (count,
attempts max, failed, cost, p50/p95 — exact over the run's traces), the
scores table (type and direction, count, mean, range or a distribution
bar), what actually ran (models, prompts — derived from the traces, not
declared), and the harness's metadata. Below them the run's items from
GET /api/v1/runs/{id}/items: position, id, how many attempts, and every
attempt's value per score name in order — not a mean, which is the server's
to take. Unknown appends the traces that named a case the dataset does not
have at this version. A row opens the case: the expected output, and each
attempt with its trace, latency, cost, errors, scores and the output its root
observation produced. A cut payload loads whole from where it lives — the
item from its dataset, the output from /observations/{id}/io. An attempt's
trace opens one level deeper in the same panel, and ‹ Item in the
header returns, the way a session panel drills into a trace.
While a run is running the page re-reads the summary and the newest page of
items every five seconds and stops when the harness closes it; a hidden tab
does not poll. A run with no trace yet shows the two attributes to stamp and
the request that closes it.
Compare — /runs/{a}/compare/{b} renders GET /api/v1/runs/{a}/compare/{b}
and nothing else: both runs with what they ran, the traffic side by side with
the cost delta, the metadata keys the two disagree about, and per score name
both aggregates, the delta and how many cases improved, regressed or stayed —
changed rather than improved for a name without a direction. The cases
follow, each with its two values and a verdict chip per name. Changed only
(?changed=1) hides the rows whose every verdict is same on the page — the
counts in the header are about the whole pair and do not move — and Swap
is the same comparison the other way round. A row opens the case: the
expected output, and Output A / Output B — the root output of the newest
trace each run made at the case, with a link to all of its attempts when there
were several — beside the per-name pair. The comparison is reachable from a
run's Compare with… and from two ticked checkboxes on any runs table.
Score configs — name, type, direction, what the name admits (bounds or
categories), description. The form writes one: it offers the vocabularies the
API takes and applies the rules between them before the round trip — a
direction is required for a numeric or boolean name and refused for a
categorical or text one, bounds belong to a number, categories to a
categorical name — with the server as the oracle either way. It PUTs the
whole config, so the same form creates and edits and a re-save of an
unchanged config writes nothing. Remove takes the binding only: the scores
already posted under that name stay exactly as they are.
Adding a case from a trace — every observation panel has Add to dataset. It opens the editor with that observation's input as the case and its output as what a good answer looks like — both fetched whole, never from a preview, because a cut document saved as a test case is a wrong test case — and with the trace and observation recorded as where the case came from. The dataset is chosen on the editor page, since the reader arrives from a trace with no dataset in mind. The output arrives as a starting point: a golden case is usually what the model said with a correction, which is why the gesture lands in an editor rather than in a saved toast.
Every empty state teaches the CLI: a project with no datasets shows the whole
loop in six lines, a dataset with no items the push that fills it, a run
with no traces the attributes that link them.
Queues
The fourth child of Evals is the review programmes of annotation.md: a named list of traces somebody decided deserve a human verdict, and the score names that verdict is made of.
Queues — one row per queue over GET /api/v1/queues, whole and in name
order (a project has as many queues as review programmes, so there is nothing
to page): name, description, the scores as chips, and a bar of how far it has
got — completed filled, skipped hatched. A skip is progress that produced no
verdict, which is why it is not the same colour. New queue takes a name and
a multi-select over the project's score configs, in the order a
reviewer will be asked for them; a project that has declared none is sent to
declare one, because a queue may only name a config that exists.
Queue — the header carries the description, the score chips and the same
progress bar, plus Start annotating and Delete. Under it, the items in the
order they were added, which is the order they are worked in: position,
target, status, who finished with it, when, and the skip reason. ?status=
narrows it, and Mine (?account=me) keeps the items you hold or finished.
Who finished with an item is named as a score's author is — muted, with the
reason in the tooltip, when they are no longer in the project. A row opens the trace in the peek panel — that is what an item
points at — and an item that names an observation opens on it. Reopen puts a
completed or skipped item back in the queue; Remove takes it out of the list
and touches no score. Delete is the echo ceremony every destructive act
wears, and its note says the part that matters: the scores written while
annotating stay.
The desk (/queues/{name}/annotate) is the point of the section: read,
judge, next, with nothing to navigate. Who is reviewing is who is signed in:
the server holds the claim and the completion by the account, the desk asks
nobody for a name, and the header shows yours without offering to change it,
because reviewing as somebody else is signing in as them. Every score it
writes has the account as its author. Then the
trace on the left, exactly as every other screen shows it, and on the right
one control per score the queue asks for, built by the same rule the Score
dialog uses: the config decides the field.
The form arrives prefilled from the scores already on the target, whoever wrote them — a judge's verdict is a verdict, and the reviewer confirms or edits it rather than repeating it. Complete & next posts what changed and then completes the item; the server checks the shape against the stored scores and a refusal marks the controls it names. Skip… asks for a reason. Later releases the claim and leaves, so nobody waits out its ten minutes.
An item is claimed for ten minutes when it is handed out, so two people at one
desk do not review one trace twice; a reload resumes the same item rather than
moving the reviewer mid-verdict, and ?item= in the URL says which. When
there is nothing to take, the screen says whether the queue is finished or
whether the rest is claimed by somebody else.
Adding to a queue — two gestures, each where the choice is made. A trace header and an observation panel carry Add to queue: pick the programme, and the answer says added or already in the queue. The traces listing carries Add to queue… beside the filter bar: it names how many traces the filters match before the call, queues the newest of them up to the endpoint's cap of 1,000, and above that cap it is disabled with the reason — a queue is a list somebody has to work through.
Deleting traces
The one way a trace leaves the store by hand (admin.md), offered at the two surfaces where the choice is made and to editors only, like Add to queue beside which each of them sits.
On the trace header — Delete… on a trace, in the peek panel or on the full page. The dialog opens on the server's dry run — the observations, scores and queue items that go with the trace, the eval runs that would lose it, and the note that the raw OTLP body stays — with the trace id already filled in as the echo: it is on screen, and typing thirty-two hex characters back is a ritual, not a check. Delete this trace is the act. From the peek panel the panel closes and the listing under it re-reads; from the trace's own page the page leaves for the listing; from a session, the session is re-read one trace shorter; from the desk, the item went with the trace and the desk moves on.
On the traces listing — Delete… beside Add to queue…. The dialog
names what it is about before it asks for anything: the listing's own count,
the active filters as the same chips the bar shows, the search text, and the
moment the set is closed at — the filter's own to, or, when it has none,
the moment the dialog opened, said in so many words, because an empty
filter is every trace before now and the wording has to make that plain.
The dry run counts the match exactly where the listing's count stops at a
thousand. The echo is the project's name; then the deletion runs in rounds
of a thousand, a line saying 2,000 of 12,000 deleted, and Stop beside it
finishes the round in flight and leaves the rest — every round is a complete,
consistent act, and a person who sees the number and changes their mind must
not need to close the tab. The listing re-reads when it is over.
Quality
The fifth child of Evals is what the evals and the reviewers have been saying, over time: quality.md in a screen. It sits with the eval nouns rather than with the traffic on the dashboard, because quality is what evals produce; the dashboard's Quality block draws the six busiest of these cards and leads here.
The filter bar is the dashboard's: the time window (?from=&to=, the one this
browser remembers or the last 30 days), the
environment box, and the minutely/hourly/daily choice (?group_by=).
Quality (/quality) is a card per score name in the window, over one
request. A card is the name, its data type, how many scores it holds, and a
small chart of what the name says: the mean for a numeric one, the rate of true
for a boolean one, and one line per category — as a share of the bucket — for a
categorical one. A numeric name whose score config pins a min and a
max is drawn against that axis rather than against its own spread, so a stable
score does not read as a cliff; a name without a config still shows, because the
trend is real whether or not anybody declared it. Configured names come first,
in the configs' order. The whole card is a link.
The endpoint returns the fifty busiest names, so a project that files more than that gets "N of M score names" in the header and a line under the cards saying how many are not shown — a shorter grid that looked complete would be the one thing worse than a truncated one (quality.md).
One score (/quality?name=X) is the dashboard's composition for one
name: the trend full width — with the minimum and the maximum as two fainter
lines behind a numeric mean — a count chart beside it, and three breakdown
tables under them: by model, by environment, by release. Each row is the key,
how many scores, and the same summary the card draws.
The model breakdown is the question a judge score on generations exists to
answer, and it is empty for a score written on the trace: a trace has no model.
That is not a bug in the screen — the API says so in its targets field, and
quality.md says why.
A window with no scores in it says so and shows the SDK line that starts
recording one; a ?name= the window does not hold gets the same answer for that
name rather than a not-found page.
Turning pages
Every listing sits on a bar: how many rows per page, what is on screen
against what matches, and four ways to move — « newest, ‹ previous, next ›,
oldest ». The page is in the URL (?limit=&cursor=&direction=), so a reload
comes back to it and a link carries it.
There are no page numbers, and the reason is the same one that makes the
listing fast. Pagination is a keyset: a page is found by seeking to a
cursor, so page four hundred costs what page one costs — and both ends are
just a direction to read the index in, which is why « and » are as cheap as ‹
and ›. An ordinal ("page 12 of 40") would need OFFSET, which counts and
discards every row it skips, plus a full count on every filter change. In a
listing sorted by time an ordinal is not an address anyway: nobody wants page
40, they want 26 August — and the time range control answers that.
The count beside the rows is capped at 1000 and reads 1000+ past that. It
follows the filters, not the page, so turning a page does not re-count. The
cap bounds the number, not always the work behind it — see
Counting for where that costs something — but it is always
a fraction of what the listing on the same filter already costs.
Live only runs on the newest page. Anywhere else the toggle is paused and says why: re-reading the newest page would replace the page you navigated to.
The peek panel
A row does not navigate away from its listing. It opens in a panel that slides in from the right, over the listing, which stays where it was — with its filters, the pages you already loaded and the position you scrolled to. Reading a listing is a loop, and this is the loop:
- Click a row to open it, and click another to swap the panel over to it; the row it is showing stays lit. ⌘/Ctrl-click still opens the full page in a new tab, because the row is a real link to it. Dragging across a cell selects its text as it would anywhere else — an id in a listing is something you copy into a terminal — and a click that ended a selection opens nothing.
kandj, or the two chevrons in the panel's header, walk the rows — the buttons carry the key they answer to. On the last row of a pagejturns the page and opens the first row of the next one, so a scan does not stop at a boundary that is an artefact of paging; both keys dim only at the ends of the whole listing. If the panel is open on a row the page no longer holds — a link somebody sent, a live tick that pushed it out of the newest page — the keys still mean what they say, and go to the nearest row that way. A letter typed into a filter field is a letter, not a shortcut.- ⤢ opens what the panel is showing as a full page —
/traces/{id}or/sessions/{id}, the selected observation included. That is the link to send somebody. - Escape, or ✕, closes it. So does the browser's Back button: what
the panel shows is in the URL (
?peek=), so a reload comes back to it.
A session's panel goes one level deeper: a trace in its table replaces the panel's body, and ‹ Session in the header returns to the session. On a phone the panel covers the screen, which is the whole reason it is there — closing it costs nothing, where a page navigation would read the listing again.
Dashboard
The project's front page (/p/{id}/dashboard; the old /stats address
redirects here, query and all). It is the screen that was Stats with the
row a front page needs above it: figures with their movement, the charts and
breakdowns, the quality cards, when the last trace arrived, and — for a
project with no traces yet — the instructions for the first one.
The summary row: four tiles for the window on screen — Traces, Cost,
Errors (failed traces as a share of all) and Latency (the p95) — each with
its change against the previous window, the same length ending where this
one begins. Traces and cost move as a signed percentage, the error rate in
points, the p95 as a signed duration, with an arrow; cost, errors and latency
colour an increase as worse, traces neither, because more traffic is the
denominator and not a verdict. The previous figure is in the tile's tooltip.
A tile whose figure is absent — nothing priced, nothing timed, no traces —
shows a dash and no change. A change against an absent previous figure reads
no earlier data — a window whose previous window predates the project, or predates
what the rollup still keeps — and so do traces and cost against a previous of
zero, which no percentage can be taken against; the error rate and the p95
are differences and read the difference, so errors appearing where there were
none is +3 pt, marked worse. The figures are two
requests for GET /api/v1/stats?group_by=total, this window's and the
previous one's (api.md); the subtraction is the only
arithmetic the screen does.
Last trace in the header — Last trace 4 min ago, the instant in the tooltip — is the newest row of the trace listing with the environment filter and no window, because the one thing a window of statistics cannot say is whether anything is arriving now. No traces yet when the listing is empty. It is read with the rest of the page and by Refresh, never polled: the Traces live toggle is the one poller in the app.
The charts — traces, cost, tokens (input, output and cache read as three lines, with reasoning and cache write in the legend, hidden until their entry is clicked, so the drawn lines stay the ones that do not double-count; where they are the only lines with data they are drawn, and a click on the legend survives a redraw), latency (p50 and p95) and errors — share one x cursor, and the breakdown tables by model, by environment and by release carry proportion bars and a Tokens column of input plus output, what a bill is made of; the cell's tooltip lists every class the group reported, reasoning and cache write among them. Cache read is on the chart, where it explains a bill that is smaller than the tokens suggest, and out of the column, where it would count the same tokens twice for the providers that report cached tokens inside the input. The bucket switcher is minutely/hourly/daily and defaults to minutes for windows up to two hours that end within the last day, hours up to 48 hours, days above — Last hour is sixty points, Last 24 hours twenty-four. Minutes are offered for windows of 24 hours or less that end within the last day; past that the button is disabled, the reason is written beside it, and a link that asks for minutes reads as if it asked for nothing. The Quality block stays hourly under a minute timeline: scores are rolled up by the hour. In the release table, the traces that named none are one row called (no release) rather than a row that is missing.
A bucket the server did not return is drawn as a gap, never as a zero,
and a bucket that reported no cost has no cost point — nor a token point when
none of its calls reported usage, and a table row without one shows —: the
API refuses to fabricate rows and so does the screen. A value with a gap on
both sides is drawn as a point, so a burst in one hour of a week shows
where it is rather than leaving the chart blank. An empty window says so
rather than drawing an empty frame. A project with traces but none in the
window shows dashes, empty axes and the true last-trace time: not the
instructions.
The numbers behind the charts come from an hourly rollup for closed hours and from the live rows for the hour in progress (a minute timeline reads the live rows alone), which is why a month reads as fast as a day and why the charts keep answering about data retention has since deleted. The latency percentiles are histogram-based, accurate to a few percent — see api.md.
Quality — up to six score names of the window, the ones with the most scores first, each drawn as the Quality overview draws its card and leading to that score's detail, with See all when more names exist. The block is not drawn when the window holds no score. One request, and none while the block is hidden.
A fresh project — no traces at all, which is Last trace saying so — shows, in place of the blocks, the exporter settings with a copy button and the quickstart link. The filter bar stays.
Customize in the header enters a mode in which every block wears a handle and a Hide control. Blocks are reordered by dragging the handle — or by keyboard: focus the handle, space to lift, arrows to move, space to drop, with the moves announced — and the order is one list across the summary, the charts, the breakdowns and the quality cards, so a chart may sit above the summary if that is what you want. Hidden blocks are listed in a strip at the bottom with Show; Reset restores the default; Done leaves the mode. A hidden block makes no request of its own: the timeline request serves the five charts and is made while any of them shows, and the breakdowns and the quality cards are each their own request. A person who asked for less motion gets no animation while dragging; the keyboard path is unchanged.
The arrangement is kept on the account, per project, under
preferences.dashboard (accounts.md), so it
follows you between browsers and is yours whatever your role in the project.
Every change in Customize mode is written at once; a write the server refuses
is shown in the strip, and the arrangement on screen stays what you made.
Two tabs writing at once is last write wins.
Neither the dashboard nor the Quality screen has a live mode. Both re-read on a filter change and on the Refresh control.
Users — sits between Sessions and Prompts, joining the screens about who: a user is a set of sessions, and their page is the dashboard's charts for one of them.
The listing is GET /api/v1/users: id, traces, sessions, errors, cost,
tokens, first and last seen. A sort select offers the questions the endpoint answers —
last seen, traces, cost, tokens (input plus output, a user with none last),
errors, always descending — and a box narrows by a
case-sensitive prefix of the id; both live in the URL. A long id is cut in
the middle, with the whole of it in the title and a copy button beside it.
It is built from an hourly roll-up, so it trails live traffic by a few minutes and the empty state says so rather than claiming there are no users: a user first seen just now is on the Traces screen, filtered by their id, and not yet here. See users.md.
A user (/users/{id}) is composed of parts the other screens already
have: the summary cards from GET /api/v1/users/{id} — traces, sessions,
errors, cost, p50/p95, first and last seen, exact including the traffic too
recent for the listing — then a time window governing an activity chart
(traces, sessions started and failing traces) and a cost chart, both over
GET /api/v1/stats?user_id=, then the environment and model breakdowns with
the same filter and the same Tokens column as the dashboard's. Under them, two tabs (?tab=sessions|traces) hold the
Sessions and Traces tables filtered by this user, each with a ⤢ to the full
listing with the filter set — so the peek panel and its j/k walk come
along rather than being reimplemented.
Erase data in the header is the Settings card with the id already filled in: the server's own dry run, the id typed back to confirm, and the listing afterwards (admin.md).
The user id in the Traces table and in a trace's header is a link here. It is
the second link in a trace row — the row's own link, the one j/k and Enter
use, stays first.
Settings — see Settings and administration.
Trace — the observation tree on the left, the selected observation on the right. A node shows its kind, name, duration, cost and whether it failed; a parent whose descendant failed is marked too, so collapsing a subtree never hides a failure. Arrow keys walk the tree — up and down move, right opens, left closes and then leaves.
Durations are written the same way everywhere — a list row, the header's
latency, a node of the tree, a percentile — and the finest unit is the
millisecond, which is what the server keeps: a trace's latency is a whole number
of milliseconds, so one shorter than that reads <1 ms and never a count of
microseconds nobody measured. A span's duration in the tree is the difference of
its two timestamps at full precision, cut to whole milliseconds as the server
cuts it, so a one-span trace shows the same figure in all three places.
The kind is an icon — a wrench for a tool call, a shield for a guardrail, a box for a plain span — each carrying its name for a tooltip and for a screen reader. There are ten kinds, which is more than a three-letter label can keep readable.
The detail panel shows the observation's timings (including TTFT, when the
client reported when its first token came back — negative if its clocks
disagreed, shown as sent rather than hidden), level, model, usage and cost,
then input, output and metadata as documents with each payload's size
beside its heading (see Payloads). Usage,
cost and model parameters are short flat records, and read as two-column lists
rather than as JSON. A generation that ran a named prompt carries a badge
saying which — support-answer · v7 — that leads to the traces which ran it.
The badge is a link to a filtered listing, not a lookup: this store need not
manage that prompt for the link to work. This is the same view the peek panel
shows — one component, two frames around it.
The header above the trace names its release when it has one, beside the timestamp, latency and cost.
Payloads
Every payload — an observation's input, output and metadata, and a
trace's own metadata — is shown in one editor surface, the same one the eval
screens write item bodies in. It is a text document, not a tree:
- Long strings are whole and wrapped. A prompt is the thing you came to read; nothing is cut at a character count and nothing scrolls sideways.
- ⌘F / Ctrl-F searches inside the payload, while it has focus. A large document is drawn a screenful at a time, so the browser's own find would miss what is scrolled away; this one searches the document itself.
- The gutter folds. A document over 400 lines opens with everything more than two levels deep folded, which is what makes a megabyte navigable; a short one opens flat. Searching for text inside a fold opens it. Folding happens once the document is on screen rather than before, so the payload is there to read either way; a document too large to fold in the time allowed opens with its head folded and the rest flat.
- Copy takes the whole document, not the part on screen. A preview under a truncation banner has no Copy at all: what is on screen there is a prefix, and the banner is how you get the rest.
- A payload that is not JSON at all — a plain-text prompt on a span that sent one — is shown as the text it is, with no quotes and no escapes.
Light and dark share one set of colours with the rest of the interface: keys,
strings, numbers and punctuation, all of them from app.css.
Images and files a payload references (media.md) are drawn above its JSON: an image as a thumbnail with its type and size under it, which opens the full picture in a new tab; any other file as a chip that downloads it; and a reference the project's setting kept no bytes for as a muted chip reading not stored (project setting). The JSON still shows every reference as data. The bytes are fetched with the page's own credentials and drawn from memory — never opened as a page of this server — so a file that declared itself HTML is still only a download. A picture is drawn under the type its own reference declared, and a download is saved with an extension only for common image, audio, video, PDF and text types; any other type is saved with none.
The response budget
A trace is fetched with ?expand=io, so the server spends its byte budget on
the payloads and replaces the ones that do not fit with truncation markers
(see api.md). The interface consumes those markers
rather than working around them:
- a truncated payload shows the preview the marker carried, under a banner
reading showing 589 B of 3.8 KB · Load the whole payload. The banner is
the button: it fetches
GET /api/v1/observations/{id}/io— the one endpoint no budget applies to — and swaps the whole document in. The preview is a prefix cut on a byte boundary, so it is shown as text rather than parsed; - when the payload holds media — an image sent after a long prompt — the panel says how many references there are (This payload holds 2 images or files, which the preview may not show), above the banner that loads the whole payload, so a picture that is in the payload never reads as dropped;
- a marker the budget left no room for a preview in is the banner alone;
- a trace with more payloads than the budget can carry markers for gets none of them, and each payload offers a load button of its own.
Nothing is fetched unasked: a wide trace would pull megabytes on open, and
which payload is worth that is the reader's call. Raising
TRACEPAD_RESPONSE_BUDGET_BYTES inlines more of them up front.
Settings and administration
Settings is three tabs, because it is three audiences. The active one is
in the address, so a tab is a link. Two of the tabs are about a project and
live under its prefix (/p/{project}/settings/project, …/settings/server);
the Server tab is about the whole server and sits there anyway, and switching
projects from either keeps the tab. The Account tab is about you and lives
bare, at /settings/account: from there Project and Server point at the
project you last looked at, and are absent when you reach none.
Project — the project on screen, for everybody who can reach it:
- Project — its name and id. Renaming is an owner's, because a project's name is the echo every destructive confirmation is typed against; the field says so to everybody else rather than simply not being there.
- Retention — all three windows, with
nullspelled out: "keep forever" for the queryable data, "follow the window above" for the raw bodies, and "keep forever" again for the statistics history, which outlives the traces it summarizes and so has a window of its own (see retention.md) — and beside them the project's media setting: store images and files once each, or keep a placeholder only (media.md). - API keys — each key's name, public key and scopes,
when and by whom it was minted, and when it was last used ("never" until it
is); a form to mint one — a name and a checkbox per scope,
ingestticked by default, since wiring an application is the usual reason to mint — and revocation. A key whose minter can no longer manage keys in the project — removed, demoted to viewer, disabled or deleted — says so on its row, because taking their access away revoked nothing (admin.md). A minted pair is shown once, with the lines that fit its scopes:TRACEPAD_API_KEYfor every key, and the OpenTelemetry and Langfuse variables only for a key withingest, so a key that cannot send is never offered to an exporter. The secret is stored as a hash and the dialog says so rather than implying it can be found again. Deleting an account on the Server tab lists the keys it minted before it asks for the email. - Danger zone — erasing everything stored about one end user.
A viewer sees the cards and one line saying why the buttons are gone —
what the project is set to is worth knowing even when changing it is not
yours to do. Retention and the keys are an editor's.
Account — everybody's own: the display name, the password (changing it signs out every other browser), and the list of browsers you are signed in on, with Sign out everywhere else.
Server — an owner's, and absent for everybody else; the address redirects too. Two tables:
- Projects — every project on this server with its retention and its status, and the way into each one: a row's name and its Settings lead to that project's Project tab, which is where the name, the retention and the keys are changed — nothing is edited from the table itself. Delete sits beside Settings behind the echo; a soft-deleted row shows its purge date and Restore instead. New project opens the keys-once dialog (the same one the sidebar's switcher opens), and a project created here is opened once its keys are put away — on this same tab, under the new project, where the table is the same one. On the Project tab an owner has All projects above the cards, the way back to this table.
- Accounts — email, name, standing (
pending/active/disabled), last login, and the projects each account reaches with the role it has. Invite opens a dialog and hands back the link once, with a copy button and its expiry. Edit changes the same fields, disables an account, or mints a fresh link. Delete is the dry run with the email typed back.
TRACEPAD_ADMIN_TOKEN is not entered anywhere in the interface any more: an
owner reaches all of this, and a credential the screens do not use is a
credential the screens should not hold.
Every destructive action is the server's dry-run/confirm contract rendered (see admin.md): the card asks the API what the change would delete, shows that answer, and enables its button only once you have typed back the identity the server named — the project's name, or the user id; deleting one trace is the one card that fills its echo in, because the id is already on screen. Nothing is counted in the browser, and a refusal is reported in the server's own words.
State in the URL
Filters, the search, live mode, the time window, the dashboard's bucket and
the selected observation all live in the query string, and the project in the
path (see Opening it), so any view is a link:
/p/{project}/dashboard?from=…&to=…&group_by=hour,
/p/{project}/traces?q=refund+failed,
/p/{project}/traces?status=error&environment=prod,
/p/{project}/traces/{id}?obs={observation_id},
/p/{project}/sessions?environment=prod,
/p/{project}/traces?environment=production,staging,
/p/{project}/users?sort=cost&prefix=acme:,
/p/{project}/users/{id}?tab=traces&from=….
Reloading, sharing and the back button all behave, and so does the same link
with the prefix left off — it lands on the remembered project.
The time window is one control on every screen that has one — presets for the
last hour, day, week and month, plus a calendar — and it travels as the
from/to the API itself takes. A preset sets from and leaves the end
open, so "the last 24 hours" keeps ending now.
The remembered window
The window you last set is remembered in this browser, per account: a preset as the preset, so "the last 24 hours" still ends now a week later; a calendar range as its days. The screens that open on a default window — the dashboard, Quality and a user's page — open on the remembered one instead when their address carries none; every screen with the control writes it, the Traces and Sessions listings included. The listings never read it: they keep opening on Any time, because a listing that silently narrowed itself to last week would hide the trace you came for. A window in the address always wins, so a link somebody sends shows their window, not yours. Nothing about it goes to the server — the arrangement of the dashboard does, the window does not — and Any time forgets it.
Appearance
Light and dark ship together and follow the operating system by default; the toggle in the sidebar pins one and remembers it.
Everything the page needs is inside the binary — fonts included. The interface makes no request to any external origin, which an air-gapped install depends on and which the end-to-end suite asserts.
The layout is usable on a phone. The sidebar becomes a slim bar on top —
the product, the project and the account — and a tab bar at the bottom with
Dashboard, Traces, Sessions, Users and More; More opens a sheet
with every other section, and lights up while one of them is on screen. The
sheet closes on Escape, a tap outside it or a swipe down from its grip. The
filters live in a popover, tables scroll inside their own box rather than
scrolling the page, and the trace screen switches between the tree and the
observation instead of showing both. The project's listings — traces, sessions, users,
prompts, datasets and their items, runs and their items, queues and their
items, score configs, the Accounts, Projects and API keys cards, the
Stats screen's and a user's breakdowns, a run's scores and a comparison's scores and cases — do not
scroll at all there (the Quality screen's breakdowns do not fold: at 240 px they fit
any box, and one with a longer label scrolls in its own box):
each keeps the columns that say which row it is and whether it went wrong —
for a trace, the time, the name and the errors — and folds the others into
lines under the row, which wrap between values rather than cut any of them,
so the environment, the latency, the TTFT and the cost read
production · 2.08 s · TTFT 180 ms · $0.0054 under the name, with the user
and the session, still links, beneath, and a session's traces read
14 traces · $0.1742. A breakdown keeps its key, its count and its errors
with their bars, and puts the cost and the tokens under the key; a compared
case lists its scores one per line under Scores. A queue's Reopen and
Remove become icons there, and an account's Edit and Delete and a
project's Settings and Delete stack beside it. A listing folds on a desktop
too wherever its box is narrower than the whole table — a narrow window or a
tablet beside the sidebar, or a session's traces inside the peek panel —
because the width it answers to is its box's, not the screen's, and in rem, so a larger default font size folds it earlier. A long id or name that a program gave is cut to its column, with the whole of it on hover, rather than widening the table past the width it folds at; what a person typed (an API key's or a project's name) wraps, and in a table's whole layout an ordinary value (a timestamp, a key, an id) is not torn across lines: where the table would need more room for that, it folds earlier. A screen's own header keeps its 48 px as
a floor rather than a height: at a phone's width, what it carries beside the
title — a breadcrumb, an identifier, a count — takes a second line under it,
and the screen's controls stay on the first one. Wider than that it is the
single row it has always been, and a name too long for the line is shortened
to an ellipsis rather than moved.
What the page may load
The interface renders text it did not write — prompts, completions, tool
arguments, whatever an application put in a span — so the page it is served in
carries a Content-Security-Policy that limits what can run in it even if some
of that text ever reached the page as markup. The server sends it with the
page and with no other response:
default-src 'none'; script-src 'self' 'sha256-…'; style-src 'self' 'unsafe-inline';
img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; worker-src 'none';
base-uri 'none'; form-action 'self'; frame-ancestors 'none'
- Script is the bundle's own files and the one inline script that starts
them, named by its hash. The server takes the hash from the page it is about
to send when it starts, so a rebuilt interface needs nothing configured.
There is no
'unsafe-inline'and no'unsafe-eval': an injected<script>, anonerror="…"or a string passed tonew Functionis refused. - Requests go to the server the page came from and nowhere else
(
connect-src 'self', andform-action 'self'for a form). A script that somehow ran could not post a key or a trace to another origin. - Pictures are the server's own,
blob:for the ones the interface draws from bytes it fetched with your session (media), anddata:for the page's empty icon. Fonts are two files in the bundle. - Style is the exception: it may be inline. The editor writes its
stylesheet into a
<style>element and the dialogs set the page'sstyleattribute, and neither can carry a hash. It is harmless to the page's data, because a style cannot fetch from anywhere the other lines do not allow. - Everything else — frames, objects, workers, a
<base>— is refused.
A refusal is a line in the browser's console. If a change to the interface
trips one, the fix is in the change; the policy is not widened without a
decision in spec 051. npm run dev
is Vite's server and sends no policy, so the production build is where to look.
A reverse proxy that adds a Content-Security-Policy of its own adds a second
policy, and the browser enforces both: it can narrow this one, never loosen it.
Builds without it
The interface is compiled by Node and Vite, which go build cannot run. A
plain source build therefore ships without it and serves a page that says so;
the API, the CLI and the MCP server of such a build are complete.
Every official artifact carries it — the release binaries and the
Docker image, whose own build runs Node before Go for exactly this
reason, and the Homebrew tap when there is one — as does a local make build,
which builds the bundle first. go install is not a supported channel for this
reason.
Working on it
The sources are in ui/: a SvelteKit SPA (adapter-static, no SSR) with
Tailwind v4, built by Vite into ui/dist. The only runtime dependencies are
bits-ui (headless primitives), Lucide (icons), uPlot (the four charts) and
CodeMirror 6 (the payload surface, imported statically and pinned by exact
version; there is no basicSetup — the extensions are listed by hand in
ui/src/lib/components/json/setup.ts).
It builds with Node 24.15 or a newer 24.x, the one Node ui/package.json names in engines:
the targets below stop at once, naming the version they found, under any
other. The type check is svelte-check on TypeScript 6.0: it imports the
compiler as a library, which TypeScript 7 has no stable API for yet, so the
typescript package is an alias to 6.0's. The interface moves to 7 when 7.1
ships that API and svelte-check runs on it.
make ui # build the bundle and stage it for embedding
make build # the above, then `go build -tags ui`
make ui-check # svelte-check, unit tests, and the API-type drift check
make e2e # boot the real binary on a temp database, run Playwright
npm run dev inside ui/ serves the app with hot reload and proxies /api
to a tracepad serve running on the default port.
The TypeScript types of the API are generated from
internal/server/openapi.json and committed; make ui-types regenerates
them, and the gate fails if the committed copy has drifted.