Context Course documentation

Hands-On: Build an Agent Activity Dashboard with Gradio

Hugging Face's logo
Join the Hugging Face community

and get access to the augmented documentation experience

to get started

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:

  1. A FastAPI endpoint at POST /event that any hook can post to.
  2. 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-dashboard

Install 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 /event is 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 Code
Codex
OpenCode
Pi

.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-me

Then 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 exit 2 with a stderr reason or print jq -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 _normalize before 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 hook headers.
  • The in-memory buffer resets on restart. For durable history, swap deque for 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 extension

Best 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