Context Course documentation
Hands-On: Build an Agent Activity Dashboard with Gradio
Hands-On: Build an Agent Activity Dashboard with Gradio
This project wires the hooks you configured in the previous lesson into a live dashboard. The dashboard is a Gradio app that accepts hook events over HTTP, shows the most recent calls in a table, and plots tool usage over time. It works against Claude Code, Codex, OpenCode, and Pi — with the same agent doing the work, you see what actually happens under the hood.
What You’ll Build
One Python process that does two things at once:
- A FastAPI endpoint at
POST /eventthat any hook can post to. - A Gradio app mounted on the same server that renders the events live.
The Gradio app polls the in-memory event buffer and re-renders every second, so you can watch the agent work in real time.
Project Setup
Create a fresh project directory:
mkdir agent-activity-dashboard
cd agent-activity-dashboardInstall dependencies:
pip install "gradio>=4.41" "fastapi" "uvicorn[standard]" "pandas"Create requirements.txt for later deployment:
gradio>=4.41 fastapi uvicorn[standard] pandas
Step 1: Build the Receiver and Dashboard
Create app.py:
import datetime as dt
from collections import Counter, deque
from typing import Any
import gradio as gr
import pandas as pd
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
# ---------- Shared state ----------
MAX_EVENTS = 500
events: deque[dict[str, Any]] = deque(maxlen=MAX_EVENTS)
def _truncate(value: Any, n: int) -> str:
text = "" if value is None else str(value)
return text if len(text) <= n else text[: n - 1] + "…"
def _normalize(body: dict[str, Any], headers: dict[str, str]) -> dict[str, Any]:
"""Map Claude Code / Codex / OpenCode / Pi payloads to one shape."""
platform = (
body.get("platform")
or headers.get("x-platform")
or "unknown"
)
event_name = body.get("event") or body.get("hook_event_name") or "Unknown"
tool = body.get("tool") or body.get("tool_name") or ""
args = body.get("args") or body.get("tool_input") or body.get("prompt") or ""
return {
"timestamp": dt.datetime.utcnow().isoformat(timespec="seconds") + "Z",
"platform": str(platform),
"event": str(event_name),
"tool": str(tool),
"args": _truncate(args, 200),
}
# ---------- FastAPI receiver ----------
api = FastAPI(title="Agent Activity Dashboard")
@api.post("/event")
async def event(req: Request):
try:
body = await req.json()
except Exception:
body = {}
record = _normalize(body, {k.lower(): v for k, v in req.headers.items()})
events.appendleft(record)
# Return an empty body so hook callers never block on the dashboard receiver.
return JSONResponse({})
@api.get("/health")
def health():
return {"ok": True, "events": len(events)}
# ---------- Gradio views ----------
COLUMNS = ["timestamp", "platform", "event", "tool", "args"]
def events_df() -> pd.DataFrame:
if not events:
return pd.DataFrame(columns=COLUMNS)
return pd.DataFrame(list(events), columns=COLUMNS)
def tool_counts_df() -> pd.DataFrame:
counter = Counter(e["tool"] for e in events if e["tool"])
rows = [{"tool": tool, "count": n} for tool, n in counter.most_common(15)]
return pd.DataFrame(rows, columns=["tool", "count"])
def summary_md() -> str:
total = len(events)
platforms = sorted({e["platform"] for e in events}) or ["(none)"]
tools = sorted({e["tool"] for e in events if e["tool"]})
tools_display = ", ".join(tools) if tools else "(none)"
return (
f"**Events:** {total} (buffer holds up to {MAX_EVENTS}) \n"
f"**Platforms seen:** {', '.join(platforms)} \n"
f"**Tools seen:** {tools_display}"
)
def refresh():
return events_df(), tool_counts_df(), summary_md()
def clear_events():
events.clear()
return refresh()
with gr.Blocks(title="Agent Activity Dashboard") as ui:
gr.Markdown("# Agent Activity Dashboard")
gr.Markdown(
"Point your Claude Code, Codex, OpenCode, or Pi hooks/extensions at "
"`POST http://localhost:8000/event` to see live activity here."
)
header = gr.Markdown(value=summary_md())
with gr.Row():
clear_btn = gr.Button("Clear events", variant="secondary")
chart = gr.BarPlot(
value=tool_counts_df(),
x="tool",
y="count",
title="Tool usage",
tooltip=["tool", "count"],
height=280,
)
table = gr.Dataframe(
value=events_df(),
headers=COLUMNS,
label="Recent events (newest first)",
wrap=True,
interactive=False,
)
# Poll the shared buffer once a second.
gr.Timer(1.0).tick(refresh, outputs=[table, chart, header])
clear_btn.click(clear_events, outputs=[table, chart, header])
# Mount Gradio on the FastAPI app so `/event` and the UI share one process.
app = gr.mount_gradio_app(api, ui, path="/")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)Run it:
python app.py
Open http://localhost:8000 in a browser. The dashboard is empty — that’s expected until a hook sends an event.
The FastAPI route for
/eventis defined before Gradio is mounted at/, so POSTs hit the receiver and everything else lands on the Gradio UI.
Step 2: Connect Your Agent
Use the hook config from the previous lesson. A quick reminder of the shape for each platform, adjusted so the normalizer in app.py can make sense of the payload.
.claude/settings.json:
{
"hooks": {
"PreToolUse": [{ "matcher": "*", "hooks": [{ "type": "http", "url": "http://localhost:8000/event", "headers": { "X-Platform": "claude-code" } }] }],
"PostToolUse": [{ "matcher": "*", "hooks": [{ "type": "http", "url": "http://localhost:8000/event", "headers": { "X-Platform": "claude-code" } }] }],
"UserPromptSubmit": [{ "hooks": [{ "type": "http", "url": "http://localhost:8000/event", "headers": { "X-Platform": "claude-code" } }] }],
"Stop": [{ "hooks": [{ "type": "http", "url": "http://localhost:8000/event", "headers": { "X-Platform": "claude-code" } }] }],
"SessionStart": [{ "hooks": [{ "type": "http", "url": "http://localhost:8000/event", "headers": { "X-Platform": "claude-code" } }] }]
}
}Claude Code’s payload already includes hook_event_name, tool_name, and tool_input, so the normalizer picks them up without extra work. The X-Platform header tags them as claude-code.
Start a new Claude Code session in this project directory and ask it to do something concrete:
List the files in this directory, then read README.md and summarize it.Step 3: Watch It Live
Leave python app.py running in one terminal and your agent running in another. As the agent works, events stream into the dashboard:
timestamp platform event tool args
2026-04-20T10:15:02Z claude-code UserPromptSubmit List the files…
2026-04-20T10:15:03Z claude-code PreToolUse Bash {"command":"ls"}
2026-04-20T10:15:03Z claude-code PostToolUse Bash {"exit_code":0,…}
2026-04-20T10:15:04Z claude-code PreToolUse Read {"path":"README…
2026-04-20T10:15:04Z claude-code PostToolUse Read {"content":"# …
2026-04-20T10:15:06Z claude-code Stop …The bar chart updates live as tools accumulate, which makes it obvious when the agent is stuck in a loop on the same tool.
Step 4: Add a Guardrail
A log-only dashboard is already useful, but the real power of hooks is that they can intervene. Extend the Claude Code hook to block dangerous commands.
Add a second hook handler for PreToolUse that runs before the dashboard logger:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.command // \"\"' | grep -Eq 'rm -rf|:\\(\\)\\{.*\\|.*&.*\\}:' && { echo 'blocked: dangerous shell pattern' >&2; exit 2; } || exit 0"
}
]
},
{
"matcher": "*",
"hooks": [
{ "type": "http", "url": "http://localhost:8000/event",
"headers": { "X-Platform": "claude-code" } }
]
}
]
}
}Now the same PreToolUse event does two things: denies obviously destructive shell patterns (exit code 2 with a stderr message) and logs everything else to the dashboard. Create a disposable test folder:
mkdir -p /tmp/hook-guardrail-demo-delete-meThen ask the agent to run:
rm -rf /tmp/hook-guardrail-demo-delete-me
You’ll see it refuse before the command executes, and you’ll see the guardrail event in the dashboard.
The equivalent OpenCode guard is a
throw new Error("blocked: dangerous shell pattern")inside"tool.execute.before". For Codex, the hook can either exit2with a stderr reason or printjq -c '{hookSpecificOutput:{hookEventName:"PreToolUse", permissionDecision:"deny", permissionDecisionReason:"…"}}'on stdout.
Step 5: Deploy (Optional)
For a team-facing dashboard, host the Gradio app on Hugging Face Spaces. Push app.py and requirements.txt, and update the hook URLs from http://localhost:8000/event to https://YOUR-USERNAME-agent-dashboard.hf.space/event.
A few cautions for a shared dashboard:
- Payloads can contain secrets (prompts, file contents, command lines). Redact sensitive fields in
_normalizebefore appending to the buffer. - A public Space is, well, public. Put authentication in front of
/event, or keep the Space private and use a personal access token in the hookheaders. - The in-memory buffer resets on restart. For durable history, swap
dequefor a SQLite file or a Spaces persistent volume.
Full Directory Layout
agent-activity-dashboard/
├── app.py # Gradio + FastAPI server
├── requirements.txt
├── .claude/
│ └── settings.json # Claude Code hooks
├── .codex/
│ └── hooks.json # Codex hooks
├── .opencode/
│ └── plugins/
│ └── dashboard.ts # OpenCode plugin
└── .pi/
└── extensions/
└── dashboard.ts # Pi extensionBest Practices Demonstrated
This project shows how to pick the right event for observability (PreToolUse and PostToolUse), how to collapse four different payload shapes into one normalized record, and how to use exit codes / hookSpecificOutput / thrown errors / returned values to enforce policy. The Gradio + FastAPI pattern — one process, two surfaces — keeps the whole thing short and makes it easy to host on Spaces.
Key Takeaways
A hook is only useful if its output goes somewhere. A Gradio dashboard is a fast way to turn raw hook events into something you can actually learn from: you can watch an agent work, see where it’s stuck, and prove that guardrails fire. The same dashboard works across Claude Code, Codex, OpenCode, and Pi because the event shape is narrow and the normalizer is small.
Next, one more quiz to wrap up the unit.
Update on GitHub