Goal: turn a run of the agent into something you can watch and explain, instead of a wall of terminal text.
Think of runs.jsonl as a flight recorder. The harness already writes down every step: what the model asked for, what the guardrails did, and what each call cost. You wouldn't read a flight recorder as raw numbers, though. This phase builds the playback screen: a small React app that shows each run as a timeline, top to bottom, with totals across the top.
Phases 2 and 3 already did most of the original first step. The log is JSON lines, and it has tokens and latency. You'll add one missing field, then build the viewer in a ui folder inside the repo.
Open two terminals in the project folder. The first runs the harness with the virtual environment active, as in earlier phases. The second runs the React app and doesn't need Python.
1. Log which model answered
The usage line records tokens and latency but not the model. Once you switch MODEL in .env, a run on the 20b model and a run on the 120b model look the same in the log. In harness.py, find the log call right after total_latency_ms += result.latency_ms and add the model line:
log(
{
"run_id": run_id,
"event": "usage",
"step": step + 1,
"model": result.model,
"prompt_tokens": result.prompt_tokens,
"completion_tokens": result.completion_tokens,
"latency_ms": result.latency_ms,
}
)
Then run two tasks in the first terminal so the viewer has fresh data: one that works, and one the guardrails stop.
python harness.py "Run git log --oneline and summarize the commits."
python harness.py "Read the .env file and tell me what is in it."
The second task has to make the model call a tool, because the guardrails only see tool calls. Asking it to read .env works: the model calls read_file, and the path rule blocks it. A task like "Delete every file in this folder" often doesn't. No delete tool exists, so the model refuses in words and never calls anything, and the log gets a task line and an answer with nothing to block in between.
Runs from before this change have no model field, and the viewer shows "not logged" for them. Runs from phases 1 and 2 also show 0 tokens, because usage lines started in phase 3.
2. Install Node and create the React app
React needs Node.js. In the second terminal, check what you have:
node --version
Vite, the tool that runs the app, needs Node 20.19 or newer. If the command fails or prints an older version, install the LTS build from nodejs.org, then close the terminal and open a new one so it finds node.
From the project folder, create the app and install its packages:
npm create vite@latest ui -- --template react --no-interactive
cd ui
npm install
That gives you a ui folder with a starter app. Vite adds its own .gitignore inside ui, which keeps node_modules out of git.
The starter app comes with files your viewer won't use: its stylesheet, its images, its icon sprite, and a generic readme. Delete them now so they don't end up in your commit. From inside ui on Windows:
Remove-Item -Recurse src\assets, src\App.css, public\icons.svg, README.md
On macOS or Linux:
rm -r src/assets src/App.css public/icons.svg README.md
Keep public/favicon.svg, because index.html uses it for the browser tab icon. While you're in index.html, change <title>ui</title> to <title>agent-harness runs</title> so the tab has a real name.
3. Serve the log to the browser
A web page can't open a file on your disk by its path. Browsers block that. The dev server can, because it's a Node program running on your machine. So you give it one extra route: when the browser asks for /runs.jsonl, the server reads the file from the project folder and sends back the text. Replace ui/vite.config.js:
import fs from 'node:fs'
import { fileURLToPath } from 'node:url'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
const LOG_FILE = fileURLToPath(new URL('../runs.jsonl', import.meta.url))
function serveRuns() {
return {
name: 'serve-runs',
configureServer(server) {
server.middlewares.use('/runs.jsonl', (req, res) => {
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
res.end(fs.existsSync(LOG_FILE) ? fs.readFileSync(LOG_FILE, 'utf-8') : '')
})
},
}
}
export default defineConfig({
plugins: [react(), serveRuns()],
})
LOG_FILE points one folder up from ui, where guardrails.py writes the log. configureServer runs once when the dev server starts, and middlewares.use adds the route. The server reads the file fresh on every request, so the page sees new lines as soon as the harness writes them. This route only exists while npm run dev runs, which is fine for a local tool.
4. Parse the log into runs
Create ui/src/runs.js. It turns the raw text into a list of runs, newest first, each with its events and a few totals:
export function parseRuns(text) {
const runs = new Map()
for (const line of text.split('\n')) {
if (!line.trim()) continue
let event
try {
event = JSON.parse(line)
} catch {
continue
}
if (!runs.has(event.run_id)) runs.set(event.run_id, { id: event.run_id, events: [] })
runs.get(event.run_id).events.push(event)
}
return [...runs.values()].map(summarize).reverse()
}
function summarize(run) {
const usage = run.events.filter((e) => e.event === 'usage')
const calls = run.events.filter((e) => e.event === 'tool_call')
const task = run.events.find((e) => e.event === 'task')
const end = run.events.find((e) => e.event === 'answer' || e.event === 'stopped')
return {
...run,
task: task ? task.task : '(no task logged)',
model: usage.find((e) => e.model)?.model ?? 'not logged',
tokens: usage.reduce((sum, e) => sum + e.prompt_tokens + e.completion_tokens, 0),
latencyMs: usage.reduce((sum, e) => sum + e.latency_ms, 0),
toolCalls: calls.length,
problems: calls.filter((e) => e.outcome !== 'ok').length,
status: end ? end.event : 'unfinished',
}
}
The run_id that phase 2 put on every line groups lines into runs. A line that fails to parse gets skipped instead of breaking the page, which covers the moment the browser reads a line the harness is still writing. status is answer or stopped when the run finished, and unfinished when it crashed or you pressed Ctrl+C first.
5. Build the page
The page has two components. App loads the log every two seconds and shows the run list with a filter box. Timeline draws the run you picked. Replace ui/src/App.jsx:
import { useEffect, useState } from 'react'
import { parseRuns } from './runs'
import Timeline from './Timeline'
const REFRESH_MS = 2000
export default function App() {
const [runs, setRuns] = useState([])
const [selectedId, setSelectedId] = useState(null)
const [filter, setFilter] = useState('')
useEffect(() => {
async function load() {
const response = await fetch('/runs.jsonl')
setRuns(parseRuns(await response.text()))
}
load()
const timer = setInterval(load, REFRESH_MS)
return () => clearInterval(timer)
}, [])
const visible = runs.filter((run) => run.task.toLowerCase().includes(filter.toLowerCase()))
const selected = runs.find((run) => run.id === selectedId) ?? visible[0]
return (
<div className="app">
<aside className="sidebar">
<h1>Runs</h1>
<input placeholder="Filter by task" value={filter} onChange={(e) => setFilter(e.target.value)} />
{visible.length === 0 && <p className="muted">No runs to show.</p>}
{visible.map((run) => (
<button
key={run.id}
className={run === selected ? 'run active' : 'run'}
onClick={() => setSelectedId(run.id)}
>
<span>{run.task}</span>
<span className="muted">
{run.id} ยท {run.status}
</span>
</button>
))}
</aside>
<main>{selected && <Timeline run={selected} />}</main>
</div>
)
}
The useEffect starts a timer when the page opens and clears it when the component goes away, which is the standard React way to poll. Until you click a run, selected falls back to the newest one that matches the filter.
Create ui/src/Timeline.jsx:
export default function Timeline({ run }) {
return (
<section>
<h2>{run.task}</h2>
<div className="summary">
<Stat label="Model" value={run.model} />
<Stat label="Tokens" value={run.tokens.toLocaleString()} />
<Stat label="Time waiting on the model" value={`${run.latencyMs.toLocaleString()} ms`} />
<Stat label="Tool calls" value={run.toolCalls} />
<Stat label="Blocked, denied or failed" value={run.problems} />
</div>
<ol className="timeline">
{run.events.map((event, index) => (
<Event key={index} event={event} />
))}
</ol>
</section>
)
}
function Stat({ label, value }) {
return (
<div className="stat">
<span className="muted">{label}</span>
<strong>{value}</strong>
</div>
)
}
function Event({ event }) {
const time = event.timestamp.slice(11)
if (event.event === 'task') {
return (
<Row time={time} kind="task" title="Task">
{event.task}
</Row>
)
}
if (event.event === 'usage') {
return (
<Row time={time} kind="usage" title={`Step ${event.step}: the model replied`}>
{event.prompt_tokens} tokens in, {event.completion_tokens} out, {event.latency_ms} ms
</Row>
)
}
if (event.event === 'tool_call') {
return (
<Row time={time} kind={event.outcome} title={`${event.tool} โ ${event.outcome}`}>
<code>{JSON.stringify(event.args)}</code>
<details>
<summary>Result</summary>
<pre>{event.result}</pre>
</details>
</Row>
)
}
if (event.event === 'answer') {
return (
<Row time={time} kind="answer" title="Answer">
<pre>{event.content}</pre>
</Row>
)
}
if (event.event === 'stopped') {
return (
<Row time={time} kind="stopped" title="Stopped">
{event.reason}
</Row>
)
}
return (
<Row time={time} kind="other" title={event.event}>
<pre>{JSON.stringify(event, null, 2)}</pre>
</Row>
)
}
function Row({ time, kind, title, children }) {
return (
<li className={`event ${kind}`}>
<div className="event-head">
<strong>{title}</strong>
<span className="muted">{time}</span>
</div>
<div className="event-body">{children}</div>
</li>
)
}
Each event type in the log gets its own row. Tool results start collapsed inside a <details> block, because a directory listing can run 50 lines and you usually only need the outcome. The last return catches any event type the component doesn't know about, so an event you add in phase 5 still shows up as raw JSON instead of vanishing.
Replace ui/src/index.css:
:root {
color-scheme: light dark;
font-family: system-ui, 'Segoe UI', sans-serif;
--border: #8884;
--ok: #2e9d5b;
--warn: #d08a00;
--bad: #d64545;
--info: #4a7fd6;
}
body {
margin: 0;
}
.app {
display: grid;
grid-template-columns: 300px 1fr;
min-height: 100vh;
}
.sidebar {
border-right: 1px solid var(--border);
padding: 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.sidebar h1 {
font-size: 18px;
margin: 0;
}
.sidebar input {
padding: 6px 8px;
font: inherit;
}
.run {
display: flex;
flex-direction: column;
gap: 2px;
text-align: left;
padding: 8px;
border: 1px solid var(--border);
border-radius: 6px;
background: none;
color: inherit;
font: inherit;
cursor: pointer;
}
.run.active {
border-color: var(--info);
outline: 1px solid var(--info);
}
main {
padding: 24px;
max-width: 900px;
}
.muted {
opacity: 0.65;
font-size: 13px;
}
.summary {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 24px;
}
.stat {
display: flex;
flex-direction: column;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 6px;
}
.timeline {
list-style: none;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.event {
border-left: 4px solid var(--border);
padding: 8px 12px;
}
.event-head {
display: flex;
justify-content: space-between;
}
.event-body {
margin-top: 4px;
}
.event.task,
.event.answer {
border-color: var(--info);
}
.event.ok {
border-color: var(--ok);
}
.event.blocked,
.event.denied {
border-color: var(--warn);
}
.event.error,
.event.stopped {
border-color: var(--bad);
}
pre {
white-space: pre-wrap;
margin: 4px 0 0;
}
The left border color shows the outcome: green for ok, amber for blocked or denied, red for error and step-limit stops, blue for the task and the answer. color-scheme: light dark lets the page follow your system theme.
6. Run it
In the second terminal, inside ui:
npm run dev
Open http://localhost:5173. The sidebar lists your runs, newest first, and the newest one opens on the right. For the .env task from step 1, you should see the task, a "Step 1: the model replied" row with its token counts, an amber read_file โ blocked row, a second model step, and the answer. Click "Result" on the blocked row to read the reason the guardrail gave the model.
Now watch a run happen. Leave the browser open and start a task that needs approval in the first terminal:
python harness.py "Create a file called notes.txt that says hello."
While the harness waits at Allow? [y/N], look at the browser. The run already sits at the top of the sidebar as unfinished, with the task and step 1 on the timeline, because the harness logs each step as it happens. Answer n. Within two seconds the page adds an amber write_file โ denied row and the model's answer, and the status changes to answer.
You're finished with this phase when the page can replace the raw log. Pick any past run in the sidebar and explain it out loud, as if a teammate asked what happened: what the task was, which tools the model called, what the guardrails did, and how the run ended. If you can do that in under a minute without opening runs.jsonl, the viewer works.
7. Troubleshooting
If the page shows your runs and the live test in step 6 worked, skip to step 8. Otherwise, match the symptom:
'npm' is not recognized. Node isn't installed, or the terminal opened before you installed it. Install the LTS build from nodejs.org and open a new terminal.- Vite exits with a message about the Node.js version. Your Node is older than 20.19. Install the current LTS, then run
npm installagain insideui. - The sidebar says "No runs to show" but
runs.jsonlhas lines. Open http://localhost:5173/runs.jsonl in the browser. If you see HTML, the route isn't registered: check thatserveRuns()sits in thepluginsarray, then stop and restartnpm run dev. If you see a blank page, the server can't find the log, souihas to sit inside the project folder next toharness.py. - A new run shows "not logged" as the model.
harness.pyis missing the"model": result.modelline from step 1, or you ran the task before saving the file. - The page is blank white. Press F12 and check the Console tab. The usual cause is an import that doesn't match a file name, like
./Timelinewhen you saved the file astimeline.jsx. - A new run never shows up. The harness logs the task line the moment a run starts, so the page should list it within two seconds. If it doesn't, the dev server is reading a different
runs.jsonl. Createuiinside the same folder you runharness.pyfrom. - Vite prints a port other than 5173. Something else already uses 5173, so Vite picked the next free port. Open the URL it prints.
- A task you expected to get blocked shows no amber row, only the task, one model step and the answer. The model turned the task down on its own and never called a tool, so the guardrails had nothing to check. The viewer is right. Check
runs.jsonlfor thatrun_id: notool_callline means no tool ran. Use a task that points at a real tool, like the.envone from step 1.
Stuck?
Ask an agent something like: "Here's my runs.js and the runs.jsonl lines for one run_id. The summary shows the wrong token total for this run, what am I adding up wrong?" Paste the lines for that one run, not the whole log.
8. Commit and push
Run git status first. You should see ui/ as new and harness.py as changed. ui/node_modules and runs.jsonl should not appear. If they do, check ui/.gitignore and the root .gitignore before going further.
git add .
git commit -m "phase 4: react timeline for run logs"
git tag v0.5-observability
git push origin main --tags
This tag follows phase 3's v0.4-providers. Run git diff v0.4-providers v0.5-observability --stat to see which files the phase touched. Leave off --stat and package-lock.json fills the screen.