A Claude Code agent dashboard is mostly five lines of bash and one fact most people skip.
The fact: when you run claude as a subprocess, the parent script has no way to know the session UUID unless you pre-assign it with --session-id. Without that, every dashboard you build is offline-only, because by the time you parse the UUID out of the result event, the run is already done. Pre-assignment is the prerequisite for a live view of running agents. Everything downstream is plumbing.
Direct answer, verified 2026-05-08
To build a live dashboard for Claude Code agents: pre-assign the session UUID with claude --session-id <uuid>, write a sidecar JSON at /tmp/<wrapper_pid>.json on every invocation, walk the PID and PPID tree to map launchd jobs back to the session, and tail the JSONL transcript that Claude Code already writes at ~/.claude/projects/<encoded-cwd>/<sid>.jsonl. The full implementation lives in github.com/m13v/social-autoposter, specifically scripts/run_claude.sh and bin/server.js.
Why every other writeup feels hollow
Search around for "Claude Code agent dashboard" and the results split into two piles. One pile is generic observability tooling that wraps any LLM call and logs token counts to a hosted backend. The other pile is aspirational mockups: a Figma file with a sparkline, a status pill, and a prose paragraph about how nice it would be if Claude Code shipped one.
Neither pile addresses the actual problem. If you are running Claude Code on a cron schedule (launchd, systemd, a CI runner, doesn't matter), the thing you want to see at 2am is which agents are alive right now, what they are doing, and a way to peek at the transcript before the watchdog timer kills them. None of that is downstream of a tracing SDK; it is upstream, in the wiring between your scheduler and the Claude CLI.
S4L runs roughly 37 launchd jobs that each spawn a sandboxed Claude Code agent against a different MCP config. The dashboard exists because we needed to debug two of them stepping on each other at 7am, and the built-in telemetry stops at the CLI's own log file, which is keyed by a UUID we never knew. Pre-assigning that UUID is what unlocks the rest. The five-step wiring below is what we ended up with after a year of treating this as a real product.
The five wiring pieces
Order matters. Skip step 1 and the rest cannot work. Skip step 4 and the dashboard reports phantom sessions for any run the watchdog SIGKILL'd. Skip step 5 and the "live" button has nothing to point at.
Five pieces
- 1
Pre-assign the UUID
Generate a v4 UUID, export it as CLAUDE_SESSION_ID, and pass it to claude --session-id. The parent now owns the identifier, so it can write it into a sidecar and into your DB rows in the same breath.
- 2
Write a /tmp sidecar per wrapper PID
On every wrapper invocation, drop a JSON file at /tmp/sa-active-claude/$$.json with the session UUID, the script tag, and the wrapper PID. The PID becomes the lifecycle handle.
- 3
Walk PID and PPID up the tree
Run ps -A -o pid=,ppid= once per status hit. For each sidecar, walk every ancestor of wrapper_pid up to PID 1, mapping the ancestor PID to the session. The launchd job's PID lands on this map automatically.
- 4
Self-GC dead sidecars on read
process.kill(wrapper_pid, 0) returns ESRCH if the process is gone. The /api/claude-active handler unlinks any sidecar whose owner is dead, which covers the SIGKILL case the EXIT trap can never catch.
- 5
Resolve session to JSONL on click
The live link points at /api/claude-jsonl/<sid>. The server first checks ~/.claude/projects/<encoded-cwd>/<sid>.jsonl (live), then the archive directory the post-run logger writes to. Stream the last 200KB.
Piece 1: the wrapper that pre-assigns and side-cars
This is the entire trick. The wrapper takes the place of a direct claude -p call in every cron-fired pipeline. It generates (or accepts) a UUID, exports it for any downstream Python loggers that need to stamp DB rows with claude_session_id, drops a sidecar, and only then forks the CLI.
# scripts/run_claude.sh (S4L, abridged)
SESSION_ID="${CLAUDE_SESSION_ID:-$(uuidgen | tr 'A-Z' 'a-z')}"
export CLAUDE_SESSION_ID="$SESSION_ID"
ACTIVE_DIR="/tmp/sa-active-claude"
mkdir -p "$ACTIVE_DIR" 2>/dev/null || true
ACTIVE_FILE="$ACTIVE_DIR/$$.json"
# Sidecar refreshed on every attempt. The dashboard reads this dir
# and uses the wrapper's PID as the GC handle.
cat > "$ACTIVE_FILE" <<EOF
{
"session_id": "$SESSION_ID",
"script_tag": "$SCRIPT_TAG",
"wrapper_pid": $$,
"started_at": "$START",
"attempt": $attempt,
"platform": "${SA_PIPELINE_PLATFORM:-}"
}
EOF
trap 'rm -f "$ACTIVE_FILE" "$SIDE_LOG"' EXIT INT TERM HUP
# Run claude with the pre-assigned UUID. Without this flag the
# parent has no way to tell the dashboard which transcript to tail.
claude --session-id "$SESSION_ID" "$@"Two details worth flagging. First, the sidecar gets rewritten on every retry attempt, because the wrapper rotates the UUID after an AUP refusal (the prior session may have been flagged backend-side). The dashboard always shows the currently-live UUID, never the abandoned one. Second, the trap covers EXIT, INT, TERM, and HUP, but not KILL, which is uncatchable. That is what step 4 is for.
Piece 2: the active-sessions endpoint
/api/claude-active is the source of truth for "what is alive right now." It readdirSyncs the sidecar directory, parses each file, GCs anything whose owner PID is dead, and returns the survivors. Because the cost is one readdir plus one process.kill probe per file, it can serve a 1-2 second polling cadence from the dashboard without breathing hard.
// bin/server.js — GET /api/claude-active
const ACTIVE_CLAUDE_DIR = "/tmp/sa-active-claude";
if (p === "/api/claude-active" && req.method === "GET") {
const entries = [];
for (const f of fs.readdirSync(ACTIVE_CLAUDE_DIR)) {
if (!f.endsWith(".json")) continue;
const fpath = path.join(ACTIVE_CLAUDE_DIR, f);
let obj;
try { obj = JSON.parse(fs.readFileSync(fpath, "utf8")); }
catch { continue; }
// GC: if the wrapper's pid is gone (likely SIGKILL by the
// watchdog), the trap never ran. Drop the sidecar now so the
// UI doesn't keep showing a phantom live session.
try { process.kill(obj.wrapper_pid, 0); }
catch {
try { fs.unlinkSync(fpath); } catch {}
continue;
}
entries.push({
session_id: obj.session_id,
script_tag: obj.script_tag,
wrapper_pid: obj.wrapper_pid,
started_at: obj.started_at,
attempt: obj.attempt,
platform: obj.platform,
jsonl_url: "/api/claude-jsonl/" + obj.session_id,
});
}
return json(res, { sessions: entries });
}The polling pattern is a deliberate choice. We considered inotify and chokidar, but the sidecar directory churns every few seconds in a 37-job rotation, and watcher events drop on macOS under heavy fs traffic. A flat readdir on every hit is dumber and more reliable.
Piece 3: matching launchd jobs to live sessions
The dashboard's status route hands you a row per launchd label, with pids[] of every process matching that label. The hard part is turning that into "here is the live Claude session for this row." The launchd PID is rarely the same as the wrapper PID; usually it is a parent shell that exec'd the wrapper, and the wrapper itself is a child or a grandchild.
The cheap fix is one ps call per status hit, which builds a PID-to-PPID map across the whole tree. For each sidecar, we walk every ancestor of wrapper_pid up to PID 1 (capped at depth 32 with a seen-set guard against cycles), and index the sidecar by every ancestor PID. The launchd job's PID is one of those ancestors, so the row-to-session lookup is O(1) after the walk.
Piece 4: the GC trick that survives SIGKILL
Every long-lived dashboard for cron-fired agents needs an answer to "what if the watchdog kills the wrapper before the EXIT trap runs?" In our case the watchdog (scripts/watchdog_hung_runs.py) escalates from SIGTERM to SIGKILL after 30 seconds, and SIGKILL is uncatchable, so the trap can never delete the sidecar.
The GC pattern: on every read of the active directory, probe each sidecar's wrapper PID with process.kill(pid, 0). That sends signal 0, which does not actually deliver a signal but does run the same permission check the kernel would do for a real signal. If the process is gone, you get ESRCH, which we treat as "owner is dead, unlink this sidecar." Within one polling cycle of the watchdog kill, the dashboard stops showing a phantom live session for that row.
This is the part that no other "agent dashboard" writeup mentions, because most of them assume the agent process exits cleanly. In a real cron pipeline that runs 37 jobs across five Chrome profiles, agents get killed rough and often. Your dashboard's GC is your dashboard's reputation.
Piece 5: tail the transcript Claude already writes
You do not need to ship JSONL through your own log collector. Claude Code already writes a JSONL transcript per session at ~/.claude/projects/<encoded-cwd>/<sid>.jsonl, and that file is appended to as the agent turns. The live link in the dashboard just points at an endpoint that tails the last 200KB of that file.
// bin/server.js — GET /api/claude-jsonl/:session_id
// Resolution order:
// 1. live transcript: ~/.claude/projects/<encoded-cwd>/<sid>.jsonl
// 2. archived transcript: skill/logs/claude-sessions/<date>/*<sid>.jsonl
const m = p.match(/^\/api\/claude-jsonl\/([0-9a-fA-F-]{8,})$/);
if (m && req.method === "GET") {
const sid = m[1];
// 1. live JSONL written by Claude Code itself.
let target = null;
const projRoot = path.join(os.homedir(), ".claude", "projects");
for (const sub of fs.readdirSync(projRoot)) {
const candidate = path.join(projRoot, sub, sid + ".jsonl");
if (fs.existsSync(candidate)) { target = candidate; break; }
}
// 2. archived JSONL written by log_claude_session.py at exit.
if (!target) {
// ...recursive scan under skill/logs/claude-sessions, bounded.
}
// Tail the last 200KB so a curl pipe can read what the model
// was doing right before a watchdog killed the phase.
const stat = fs.statSync(target);
const start = Math.max(0, stat.size - 200 * 1024);
res.setHeader("X-SA-Transcript-Path", target);
res.setHeader("X-SA-Transcript-Total-Bytes", String(stat.size));
fs.createReadStream(target, { start }).pipe(res);
}Note the fallback to the archive directory. After the session ends, log_claude_session.py copies the JSONL to skill/logs/claude-sessions/<date>/<tag>_<sid>.jsonl and the dashboard's job-history view continues to serve the same URL even after the live session is gone. One URL, two backing stores, no client-side branching.
The handshake, end to end
Sidecar lifecycle from launchd fire to dashboard render
What lives in each sidecar
The sidecar is the smallest thing that makes the live link possible. Seven fields, no schema migrations, no retention policy. If you want richer metadata (token counts, files modified), pull it from the JSONL transcript when the user clicks; do not duplicate it in the sidecar.
Per-session sidecar fields
- session_id (UUID assigned by the parent, mirrored into DB rows)
- script_tag (e.g. run-twitter-cycle, run-reddit-engage)
- wrapper_pid (the PID of the run_claude.sh shell, used as GC handle)
- started_at (ISO timestamp from before the claude invocation)
- attempt (incremented on AUP-refusal retry, the dashboard tooltip surfaces it)
- platform (twitter/reddit/github, derived from --mcp-config)
- orchestrator_cost_usd (parsed from streamRes.total_cost_usd in the result event)
Numbers worth quoting
A 200KB tail is enough to hold roughly the last 30 to 60 tool calls on a typical orchestrator turn, which is what you actually want when an agent dies and you are asking "what was it about to do?" The 1.5-second status cache means the dashboard can poll once a second per open tab without ever hitting the underlying ps and readdir twice. Exit code 79 is the wrapper's signal that the run was skipped because of an org-level quota stamp; the dashboard renders that as a separate badge so you do not confuse a quota skip with a real failure.
What this lets you do that hosted observability cannot
Hosted LLM observability platforms see what your code ships them: a tool call, a token count, a final answer. They are great for cost reports and per-prompt regression tracking. They do not see what happened on the host, which is where most cron-agent failures actually live.
With the wiring above, you can answer questions a hosted dashboard cannot: why did the watchdog kill this run after 27 minutes (because it spawned a runaway find / that burned CPU on PID 3187 long after the orchestrator exited); which sibling cron job was holding the Chrome profile lock when this one started waiting; what was the last tool call before the SIGTERM landed, byte for byte. Every one of those answers comes from a single curl against /api/claude-jsonl/<sid> or a glance at the dashboard's row.
The S4L dashboard is not trying to be a Sentry for agents. It is the operational layer underneath: who is running, what are they doing, and where is the transcript when they die.
Try it on your own setup
You do not have to clone S4L. The five wiring pieces are portable. The smallest version is roughly 50 lines of shell plus 30 lines of HTTP handler. Start with one launchd or cron job that today calls claude -p "..." directly. Wrap it. Add the sidecar. Stand up an endpoint that lists the directory. You now have a live agent view for that one job.
Once that works, the second job is free, because the sidecar protocol is identical. The third is free for the same reason. By the time you are at five jobs, you have a dashboard that beats anything hosted, because it knows the one thing nobody else does: which Claude UUID belongs to which of your scheduled processes, right now.
The full source for the version we run, all 12,800 lines of bin/server.js included, is at github.com/m13v/social-autoposter.
Want help wiring a Claude Code agent dashboard for your own cron stack?
20-minute call. Bring your run_claude.sh equivalent and I'll walk you through the sidecar protocol and the GC story.
Frequently asked questions
Why do I need to pre-assign the session UUID?
Because Claude Code generates the UUID itself when you don't pass --session-id, and there is no clean signal back to the parent script telling it what UUID was chosen. By the time you parse the result event from streamed JSON, the run is already over. If you want the dashboard to show a live link while the agent is mid-flight, the parent has to know the UUID before it forks the CLI. Pre-assigning with claude --session-id <uuid> is the only honest answer.
Why a /tmp sidecar instead of a database row or a Unix socket?
A sidecar in /tmp is checked into existence by the wrapper start, removed by the EXIT trap, and self-GC'd by the API on read. It survives the wrapper getting SIGKILL'd because the API can detect a dead owner with process.kill(pid, 0). A database row needs a writeback on death, which SIGKILL by definition does not give you. A Unix socket implies a long-lived listener, which is exactly the babysitting cost we are trying to avoid. The sidecar pattern handles graceful exit, watchdog kill, and watchdog-survives-but-claude-dies all the same way.
How does the dashboard know which launchd job a session belongs to?
It does not need to know directly. /api/status hands you a list of pids[] for each launchd job (gathered from launchctl list output and ps). The active-session loader builds a Map keyed by every ancestor PID of every wrapper, by walking ps -A -o pid=,ppid= up to PID 1. The launchd job's PID is one of those ancestors, so a one-line lookup in the map gives you the session for that job. No naming convention, no extra registry.
Why no SSE or websocket for the live transcript?
The /api/claude-jsonl endpoint serves a flat 200KB tail of the JSONL file. Click the live link, it opens in a new tab, that is it. No streaming connection per pipeline, no reconnect logic, no babysitting. If you want continuous tailing you can curl it on a 1-2 second loop, and the X-SA-Transcript-Total-Bytes header tells you the file size so you only fetch new bytes. Keeping the protocol stateless was a deliberate choice; the dashboard process already has a 1.5s status cache and we did not want to layer another long-lived connection per active job on top of that.
What happens to the live link if the watchdog kills run_claude.sh mid-flight?
Two things. First, the next /api/claude-active hit notices the wrapper PID is gone (process.kill returns ESRCH), unlinks the sidecar, and the live badge disappears from the row. Second, the JSONL file at ~/.claude/projects/<encoded-cwd>/<sid>.jsonl is still on disk, since the CLI itself wrote it as it went. /api/claude-jsonl/<sid> still serves it, so an investigator can pull up the transcript for a session that died seconds ago and see exactly what Claude was about to do when the watchdog SIGTERM landed. log_claude_session.py also archives a copy under skill/logs/claude-sessions/<date>/ at exit, so even if you nuke ~/.claude later, the transcript survives.
Does this work with parallel Claude Code agents on the same machine?
Yes, that is the entire reason the design exists. S4L runs a per-platform fan-out where one parent skill spawns child Claude Code processes against five different MCP configs (Reddit, Twitter, GitHub, and two browser-profile sandboxes). Each child has its own pre-assigned UUID and its own wrapper PID, so each gets its own sidecar. The dashboard's matrix view renders one row per (platform, job-type) cell, and each cell's live link lands on the right transcript because the ancestor walk segregates them by PID lineage.
Where does the cost-per-session number come from?
When you run claude with --output-format stream-json or json, the final result event of every clean turn includes total_cost_usd computed by the SDK against Anthropic's billed pricing. The wrapper tees stdout to a side log, greps the last total_cost_usd value out at exit, and passes it to log_claude_session.py as orchestrator-cost-usd. That row lands in the claude_sessions table and is what the dashboard's funnel-stats and cost-stats endpoints read from. If the run dies before emitting a result event (rare, usually only if claude itself crashed), the column is left NULL and the page shows '-' rather than a fabricated number.