Sentinel Presenter
Cinematic demo mode for the Agent Sentinel security monitoring system
Overview
The Sentinel Presenter is a cinematic playback layer for the Agent Sentinel demo. It reuses the dashboard's visual foundation — the same world map, agent nodes, signal animations, and dark-ops aesthetic — but adds dramatic focus, smooth camera-like zooms, and narrative pacing designed for conference talks and screen recordings.
Instead of showing everything at once like the monitoring dashboard, the presenter shows one agent at a time, zooming the map to its geographic position and displaying its LLM activity in a focus overlay panel. When a different agent becomes active, the map smoothly pans to the new location.
Quick Start
Prerequisites
- PubNub keys in
examples/agent-sentinel/.env - Recordings in
examples/agent-sentinel/recordings/ - Python venv with bedsheet installed
Launch
# Standard presenter mode
cd examples/agent-sentinel
./start.sh --present
# Cinematic mode (no UI chrome)
./start.sh --present --cinematic
# Custom replay speed
./start.sh --replay 0.5 --present
The presenter opens at http://localhost:8765/sentinel-presenter.html.
It auto-connects to PubNub using the keys from sentinel-config.js
(generated by start.sh).
Present Mode vs Cinematic Mode
| Feature | Present | Cinematic |
|---|---|---|
| Top bar (logo, stats) | Visible | Hidden |
| Timeline bar (progress, speed) | Visible | Hidden |
| Speed controls | Visible buttons | Keyboard only (invisible) |
| World map + agent nodes | Visible | Visible |
| Focus overlay panel | Visible | Visible |
| Signal line animations | Visible | Visible |
| Chapter titles | Label in timeline bar | Full-screen cinematic cards |
| Timestamp counter | N/A | Toggle with T |
| Best for | Live conference talks | Screen recordings |
Launch Commands
| Command | Effect |
|---|---|
./start.sh --present |
Presenter mode with 0.3s replay delay |
./start.sh --present --cinematic |
Start directly in cinematic mode |
./start.sh --replay 0.5 --present |
Presenter with custom 0.5s delay between tokens |
./start.sh --present --quiet |
Presenter without terminal event logging |
./start.sh --present --no-dash |
Run agents only (open presenter manually) |
--present implies --replay. It reads from
recordings/*.jsonl and does not make live LLM API calls.
However, PubNub keys are still required for agent-to-presenter communication.
Keyboard Shortcuts
Director Controls
| Key | Action |
|---|---|
| 1 - 9 | Jump to agent scene N (director mode) |
| Space | Pause / resume auto-play |
| → | Step to next event (one at a time) |
| Shift + → | Skip to next agent scene |
Speed & Display
| Key | Action |
|---|---|
| Shift + 1-5 | Set speed (0.5x, 1x, 2x, 5x, 10x) |
| F | Toggle fullscreen |
| C | Toggle cinematic mode |
| T | Toggle timestamp counter |
| Esc | Exit fullscreen / return to overview |
Pacing Strategy
For Conference Talks
- Launch with
--present(keep UI chrome visible) - Start paused (press Space)
- Use → to step through events as you narrate
- Use Shift+→ to jump to the next agent when ready
- Shift+1-5 to set speed, or bare 1-9 to jump to a specific agent's scene
For Screen Recordings
- Launch with
--present --cinematic - Press F for fullscreen
- Start Screen Studio recording
- Press Space to begin auto-play
- Use Shift+1-5 for speed (Shift+2 = 1x). Bare number keys jump to agent scenes.
- The presenter handles focus transitions and chapter cards automatically
--replay 0.1 to fill the buffer quickly, then present
at a comfortable speed.
Data Flow
The presenter is purely a visualization layer. It doesn't control the agents or modify their behavior. It subscribes to the same PubNub channels as the dashboard but presents events differently.
Chapter Detection
Chapters are detected automatically from event patterns as they arrive. A chapter title card is displayed when a chapter boundary is detected.
| Chapter | Trigger |
|---|---|
| Network Startup | PubNub connection established |
| Skill Acquisition | First tool_call from skill-acquirer |
| Malicious Install Blocked | tool_result containing "MALICIOUS", "blocked", or "data_exfiltrator" |
| Rogue Burst | 3+ consecutive tool_call events from same agent |
| Gateway Block | tool_result containing "Rate exceeded" or "Action denied" |
| Sentinel Alert | Critical/high severity alert signal |
| Quarantine Issued | alert signal with action: quarantine |
Focus & Zoom Behavior
When an agent starts producing LLM events (kind = event), the presenter:
- Smoothly zooms the map to 2.5x, centered on the agent's geographic position (0.8s transition)
- Dims all other agent nodes to 20% opacity
- Adds a glow effect to the focused agent's node
- Shows a focus overlay panel to the right of the agent with event cards streaming in
- Displays a transition label ("web-researcher → scheduler") during agent switches
Non-LLM signals (heartbeats, requests, responses, alerts) still trigger map animations (pulse rings, signal lines, broadcast rings) without changing focus.
Screen Studio Tips
- Resolution: Use a 1920x1080 browser window for best recording quality
- Background: The dark theme records well — no adjustments needed
- Mouse: Keep the mouse out of the frame. All controls are keyboard-driven
- Chapter cards: In cinematic mode, chapter titles fade in/out over ~2.5s. Allow time for them to display fully
- Audio: Add narration in post-production, or narrate live while recording
- Timestamp counter: Press T to show a subtle timestamp in the corner for a "security camera" feel
Recording Workflow
Step 1: Ensure recordings exist
# If you need fresh recordings (requires GEMINI_API_KEY):
./start.sh --record
# Wait for a full cycle, then Ctrl+C
# Check recordings:
ls -la recordings/
Step 2: Launch presenter
./start.sh --present --cinematic
Step 3: Set up recording
- Open the presenter URL in Chrome
- Press F for fullscreen
- Press Space to pause immediately
- Start Screen Studio
- Press Space to begin the demo
Step 4: During recording
- Let it auto-play, or step manually with →
- Adjust speed with Shift+1-5
- Skip boring sections with Shift+→
Director Mode
Director mode gives you manual control over scene progression. Instead of auto-playing through agents sequentially, you press a number key to jump to any agent's scene.
When the presenter collects events from PubNub, it groups them by agent and assigns each a number (1-based, in order of first appearance). The timeline bar shows the agent list:
1:web-researcher [2:scheduler] 3:skill-acquirer 4:behavior-sentinel ...
Press 2 to jump to the scheduler's scene. The map zooms to its node, the briefing types out, and its LLM events play. Press 5 to jump to a different agent at any time.
Intro Crawl
The presenter opens with a classified-terminal-style intro overlaid on the map. Text types out character by character in green monospace with a blinking cursor, setting the scene before the demo begins.
The intro covers:
- The world of autonomous AI agents and the threats they face
- The OpenClaw crisis of 2026 as a turning point
- The sentinel network as the response
Press Space, Enter, or click Begin Monitoring to dismiss. PubNub connection starts only after dismissal, so the demo doesn't begin while the audience is still reading.
Scene Commentary
Each chapter triggers a commentary overlay — a military-style typed briefing that explains what's happening to the audience. The commentary appears in an "INTELLIGENCE BRIEFING" panel at the bottom of the screen, typed out with a green blinking cursor.
Chapters with commentary:
| Chapter | Trigger |
|---|---|
| Network Startup | PubNub connection |
| Skill Acquisition | First tool call from skill-acquirer |
| Malicious Install Blocked | Blocked malicious package result |
| Rogue Burst | 3+ consecutive tool_calls from one agent |
| Gateway Block | "Rate exceeded" or "Action denied" in result |
| Sentinel Alert | Critical/high alert from a sentinel |
| Quarantine Issued | Commander issues quarantine |
Commentary begins 3 seconds after the chapter is triggered (approximately 0.5 seconds after the chapter title card fades), preventing visual overlap. It fades out 3 seconds after typing completes.
Agent Briefings
When a scene focuses on an agent, a separate Asset Briefing window types out a military-style dossier covering:
- ASSET: Name and region
- ROLE: What the agent does
- MISSION: Operational objective
- PROTECTS AGAINST: What threats this agent counters
- FUTURE OPS: Planned capabilities (operational agents) or RISK PROFILE: Rogue behavior probability (worker agents)
The briefing window is positioned separately from the LLM activity window using collision avoidance (see Collision Avoidance), so they never overlap each other or the agent node.
Signal Lines
Animated dashed lines show communication between agents on the map. In the scene-based presenter, signal lines are triggered in two ways:
- PubNub signals: request/response/alert signals from the event buffer animate lines between sender and target (same as the dashboard).
- LLM tool calls: when an agent's scene shows a
tool_callevent, the presenter detects inter-agent communication:request_remote_agent→ line from caller to target agent (cyan)- Gateway tools (
search_web,install_skill, etc.) → line from agent to action-gateway (amber)
Ambient Audio
Drop a sci-fi ambient drone MP3 as docs/ambient.mp3 and it auto-plays at 15% volume
when the intro is dismissed. If the file doesn't exist, audio is silently skipped.
Finding audio: Search "sci-fi ambient drone royalty free" on Freesound.org or Pixabay. Alternatively, use Gemini with Lyra to generate a custom track.
Movie Mode
Movie mode is a third peer playback mode alongside live and replay.
Fully scripted, synthetic, runs with no PubNub or recordings. Director mode
(default) means the author controls the tempo — you press N to move between
chapters, the movie never auto-advances. Kiosk-style auto-play is opt-in via &auto=1.
Activation:
# Preferred: launches a local HTTP server + opens the movie URL
./start.sh --movie
# Direct (file:// works too since movie mode has zero I/O deps)
open docs/sentinel-presenter.html?mode=movie
# Opt-in auto-advance for kiosks / conference loops
open "docs/sentinel-presenter.html?mode=movie&auto=1"
Movie mode is boot-time-immutable: the mode is resolved once from the URL query string at page load and never switches at runtime.
Director Controls
Once the intro crawl is dismissed (Space / Enter / click "Start"), you are the director. All keybindings below work in movie mode only.
| Key | Action |
|---|---|
N or → | Next chapter — advance when you're ready for the next beat |
P or ← | Previous chapter — go back one |
1–9 | Jump to chapter N — cancels pending cues, resets visuals, plays target chapter from t=0 |
R | Restart movie from chapter 0 |
Shift+1–5 | Speed: 0.5x / 1x / 1.5x / 2x / 3x (re-paces the current chapter's remaining cues) |
F | Fullscreen |
E | Toggle panel-edit mode — drag panels to author positions (see next section) |
X | Export panel positions to console + clipboard (paste into source to lock) |
C | In edit mode: clear all saved positions (resets to defaults) |
Beats within a chapter are also director-gated. A chapter splits into "beats"
at each spotlight cue — every time the camera is supposed to move to a different
agent, the movie waits for you. Non-spotlight cues (signals, commentary, signal-lines) play
automatically within each beat. So in a chapter like "Normal Operations" where the
spotlight visits web-researcher, scheduler, and skill-acquirer in sequence, you press N
three times to walk through the three beats, then once more to advance to the next chapter.
The subtle ▸ N advances — E edits panels hint in the bottom-right corner is always
there as a reminder: the director has the wheel.
Panel Positioner (Drag-and-Drop)
The focus overlay (reasoning stream) and briefing overlay (asset dossier) are positioned by
you, the director. There is no auto-placement. Positions are authored per
spotlighted agent via drag-and-drop, persisted to localStorage, and exported as
a JS snippet you paste into the source to lock them in permanently.
Workflow:
- Open the movie:
./start.sh --movie - Navigate to a chapter where the panels you want to position are visible (e.g. Chapter 3 for skill-acquirer and supply-chain-sentinel scenes)
- Press
E— a banner appears:// PANEL EDIT — drag / E to exit / X to export / C to clear. Panels get a dashed cyan outline and a grab cursor. - Drag the focus overlay and briefing overlay to the positions you want for the currently-spotlighted agent.
- Release — the console logs
[panel-edit] saved <agent> <which> <x> <y>. Position is persisted tolocalStorage. - Press
Nto advance to the next chapter. When the next agent is spotlighted, drag its panels into place. - Repeat until every agent's panels are where you want them.
- Press
X— fullPANEL_POSITIONSJS snippet printed to console and copied to clipboard. - Paste the snippet into
docs/sentinel-presenter.htmlto replace the existingvar PANEL_POSITIONS = {};declaration. Commit.
Persistence:
- While editing, positions live in
localStorage['bedsheet.panelPositions']— survives reload, survives restart. - Clearing browser data wipes your work; export with
Xfirst if you want to save. - Once pasted into source, positions are authoritative; the user's
localStorageoverrides the source so you can experiment without losing the committed default.
Defaults when no position is saved: focus overlay goes top-right (60% x / 8% y), briefing goes bottom-left (5% x / 55% y). The movie will still play, just with these safe-corner fallbacks. Drag to fix.
Chapters
All chapters are director-triggered (press N to advance) except Chapter 0 (Bedsheet Brief),
which auto-plays once you dismiss the intro crawl. Inside each chapter, cues fire automatically on
their authored t offsets; the director only controls the between-chapter boundary.
- Chapter 0 — Bedsheet Brief — pitch types out on its own (~37s). Press
Nwhen done reading. - Chapter 1 — Architecture — two-plane diagram draws in with a 3-line caption. Stays until you press
N. - Chapter 2 — Network Startup — 7 agents come online.
- Chapter 3 — Normal Operations — routine tool calls through the gateway.
- Chapter 4 — Malicious Install Blocked — supply-chain sentinel catches SHA-256 mismatch.
- Chapter 5 — Rogue Burst — web-researcher fires 5 tool calls in 2s.
- Chapter 6 — Gateway Block — deterministic rate limiter responds.
- Chapter 7 — Sentinel Alert — behavior-sentinel correlates the spike to commander.
- Chapter 8 — Quarantine Issued — commander quarantines web-researcher.
- Chapter 9 — Stable State Restored — 6 agents on mission, 1 removed.
Authoring content: all movie content lives in the MOVIE_SCRIPT array
at the bottom of docs/sentinel-presenter.html. Each chapter is an object
{ id, title, subtitle, cues: [...] }. A cue is one atomic stage direction —
one of 10 types: chapter-card, spotlight, signal,
commentary, line, reset, and the four Chapter-0 overlay
cues (movie-pitch-start, movie-pitch-end, movie-arch-start,
movie-arch-end). A boot-time linter (lintMovieScript) reports malformed
cues to the console without throwing — the movie plays best-effort past errors.
See docs/superpowers/specs/2026-04-14-sentinel-presenter-movie-mode-design.md for
full schema, semantics, and design rationale.
Authoring a New Chapter
Add your chapter as a new object in the MOVIE_SCRIPT array (bottom of
docs/sentinel-presenter.html). Chapter order in the array is playback order;
the 1–8 keys jump by index.
Step 1 — start with the chapter skeleton:
/* Chapter N — <title> (~<duration>) */
{
id: 'short-kebab-name',
title: '<what the audience sees on the chapter card>',
subtitle: '<one-line mood setter>',
cues: [
{ t: 0, type: 'chapter-card', title: 'Chapter N', subtitle: '<...>', hold_ms: 1800 },
{ t: 1800, type: 'spotlight', agent: 'web-researcher' }, // or null for overview
{ t: 2000, type: 'commentary', text: '<one-sentence narration>', hold_ms: 8000 },
// ... your signals, lines, more spotlights ...
],
},
Step 2 — add the signal events. Each signal cue is a PubNub-shaped object. Here's the idiomatic pattern for an LLM-event sequence on a single agent:
{ t: 3000, type: 'signal', signal: {
kind: 'event', sender: 'web-researcher',
payload: { event_type: 'thinking', text: 'Reasoning about X...' },
correlation_id: 'chN-wr-1'
}},
{ t: 4200, type: 'signal', signal: {
kind: 'event', sender: 'web-researcher', target: 'action-gateway',
payload: { event_type: 'tool_call', tool_name: 'search_web', tool_input: { q: 'X' } },
correlation_id: 'chN-wr-2'
}},
{ t: 5400, type: 'signal', signal: {
kind: 'event', sender: 'web-researcher',
payload: { event_type: 'tool_result', text: '5 results.' },
correlation_id: 'chN-wr-3'
}},
{ t: 6400, type: 'signal', signal: {
kind: 'event', sender: 'web-researcher',
payload: { event_type: 'completion', text: 'Done.' },
correlation_id: 'chN-wr-4'
}},
target on a tool_call makes driveMapEffectForSignal
animate a signal-line from sender to target automatically — you
don't need a separate line cue for normal tool flow.
Step 3 — move the spotlight to follow causality. When one agent's output reaches another, zoom to the receiver:
{ t: 7000, type: 'spotlight', agent: 'sentinel-commander' },
{ t: 8000, type: 'signal', signal: {
kind: 'event', sender: 'sentinel-commander',
payload: { event_type: 'thinking', text: 'Correlating alerts...' },
correlation_id: 'chN-cmd-1'
}},
Step 4 — cross-plane moments use explicit line cues.
When you want a colored line between agents that is not a tool call (e.g. a sentinel
alerting the commander), add a line cue:
{ t: 9000, type: 'signal', signal: {
kind: 'alert', sender: 'behavior-sentinel', target: 'sentinel-commander',
payload: { reason: 'rate_spike' }, correlation_id: 'chN-alert'
}},
{ t: 9100, type: 'line', from: 'behavior-sentinel', to: 'sentinel-commander', color: 'var(--purple)' },
Step 5 — set duration and end. The chapter advances automatically once the
last cue's t + hold_ms elapses. For a clean fade, end with
spotlight null:
{ t: 13500, type: 'spotlight', agent: null },
Authoring tips
- Monotonic
t. Cue timestamps within a chapter must not go backwards. The linter (lintMovieScript) will warn on boot if you violate this. - Typing math for commentary.
showCommentarytypes at 22 ms/char. Budgethold_ms≥ text length × 22ms + 2–3 s of readable hold afterward. A 100-char sentence wantshold_ms: 5000minimum. - Chapter self-sufficiency. A chapter jumped-to via the
1–8keys always starts afterresetPresenterVisuals(). If your chapter's story depends on prior state (e.g. "web-researcher is already quarantined"), either fix that state in the first cues, or accept that jump-in plays with a fresh map. - Rapid bursts render faithfully. Unlike replay mode, signal cues bypass the 800 ms
drainMapEventsthrottle — you can schedule 5tool_calls in 2 seconds and each will pulse individually (see Chapter 4 for reference). - Correlation IDs are free-form. They surface in the event-card UI but the movie engine does not use them. Use them to trace which cue is which when debugging.
- Verify with
?mode=movieand the chapter key. Reload the presenter, dismiss the intro, press the chapter number. If something looks wrong, open devtools — the linter and cue runtime both log to the console ([lint],[movie]prefixes). - Colors.
linecues accept a CSS custom property (color: 'var(--purple)') or a hex (color: '#ff3b6f'). Omit the field entirely and the color defaults to the sender's role color fromROLE_COLORS.
JavaScript Architecture Overview
The presenter is a single self-contained HTML file (~2400 lines) with inline CSS and JavaScript. No build step, no modules, no frameworks. This matches the project convention (the dashboard is structured the same way). The JS is organized into these sections:
// 1. Agent Registry & Constants
var AGENTS = { ... }; // agent positions, roles, regions
var CHANNELS = [ ... ]; // PubNub channels to subscribe to
var ROLE_COLORS = { ... }; // CSS colors per role
var AGENT_BRIEFINGS = { ... }; // military-style dossiers
var CHAPTER_COMMENTARY = { ... }; // narration per chapter
// 2. PubNub Connection
connectToPubNub() // subscribe to channels
handleMessage() // decode signals, collect into scenes
handlePresence() // track agent online status
// 3. Scene Engine
startScenePlayback() // begin after collection window
beginScene() // zoom to agent, show briefing
playNextSceneEvent() // drain events one at a time
jumpToScene(n) // director mode: jump to scene N
skipToNextAgent() // Shift+Right: skip current scene
// 4. Map Animations
zoomToAgent(name) // CSS transform zoom + pan
pulseNode() / animateSignalLine() / animateBroadcast()
presentMapEffects() // route signal to correct animation
animateLinesFromToolCall() // detect inter-agent calls in LLM events
// 5. Overlay Positioning
findPanelPosition() // collision-avoiding placement
positionBothOverlays() // place briefing + focus, avoid each other
// 6. Visual Effects
typeText() // typewriter effect (intro)
showCommentary() // typed commentary with auto-dismiss
showBriefingOverlay() // typed agent dossier
showChapterCard() // chapter title fade in/out
animateIntro() // intro sequence orchestration
Scene Engine (How Playback Works)
The scene engine is the core of the presenter. Unlike the dashboard (which renders events as they arrive), the presenter collects first, then plays back.
// Phase 1: COLLECTION
// PubNub events arrive and are sorted into per-agent buckets.
// A timer resets on each event; after 8s of silence, collection ends.
function handleMessage(event) {
var signal = decodeSignal(event.message);
// LLM events go into per-agent scenes
if (signal.kind === 'event') {
agentScenes[sender].push(signal); // grouped by agent
agentOrder.push(sender); // first-seen order
}
// Non-LLM events go to the map animation buffer
eventBuffer.push(signal);
// Reset collection timer (8s window)
clearTimeout(collectionTimer);
collectionTimer = setTimeout(startScenePlayback, 8000);
}
// Phase 2: SCENE PLAYBACK
// Plays one agent's events at a time, in order of first appearance.
function beginScene() {
var agent = agentOrder[sceneIndex];
transitionToAgent(agent); // zoom map, show briefing
setTimeout(playNextSceneEvent, 1200); // wait for zoom
}
function playNextSceneEvent() {
var signal = agentScenes[agent][sceneEventIndex++];
detectChapter(signal); // check for chapter boundaries
buildEventCard(signal); // show in focus overlay
animateLinesFromToolCall(signal); // draw signal lines
setTimeout(playNextSceneEvent, 800 / playbackSpeed);
}
// When scene finishes: sceneIndex++, beginScene() for next agent
// When all done: loop back to scene 0
Typewriter Effect
The typewriter effect is used in three places: the intro crawl, the commentary overlay, and the agent briefings. All use the same pattern:
function typeText(element, text, callback) {
var i = 0;
element.textContent = '';
// Add blinking cursor
var cursor = document.createElement('span');
cursor.className = 'cursor';
element.appendChild(cursor);
// Type one character at a time
var interval = setInterval(function() {
if (i < text.length) {
// Insert character BEFORE the cursor
element.insertBefore(
document.createTextNode(text[i]),
cursor
);
i++;
} else {
clearInterval(interval);
// Remove cursor after brief pause
setTimeout(function() { cursor.remove(); }, 600);
if (callback) callback();
}
}, 25); // ms per character
}
The cursor is a <span class="cursor"> styled as a blinking green block
using CSS animation: blink-cursor 0.7s step-end infinite. Characters are
inserted as text nodes before the cursor element, so the cursor stays at the end.
Typing speeds vary by context: intro uses 25ms/char (deliberate), commentary uses 30ms/char (brisk), briefings use 18ms/char (fast, since they're longer).
Collision Avoidance
Two overlay panels (briefing + activity) need to be positioned near the focused agent without covering: (a) the agent node itself, (b) each other. The algorithm:
function findPanelPosition(areaW, areaH, panelW, panelH,
fracX, fracY, avoidRects) {
// Try 8 candidate positions around center
var candidates = [
right, left, below, above,
top-right corner, top-left corner,
bottom-right corner, bottom-left corner
];
// Reorder based on agent's map position
// (right-side agents prefer left placement, etc.)
// Agent node is at screen center after zoom
var nodeRect = { x: center-30, y: center-30, w: 60, h: 60 };
var allAvoid = [nodeRect].concat(avoidRects);
// Pick first candidate that doesn't overlap anything
for (candidate of candidates) {
if (!overlapsAny(candidate, allAvoid)) {
return candidate;
}
}
return candidates[0]; // fallback
}
// Usage: position briefing first, then focus avoids briefing
var briefPos = findPanelPosition(..., []);
var focusPos = findPanelPosition(..., [briefingRect]);
The rectsOverlap() function is a standard AABB (axis-aligned bounding box)
collision test. The briefing is positioned first, then the focus overlay uses
the briefing's rect as an additional avoidance zone.
PubNub Integration
The presenter connects to PubNub identically to the dashboard — same channels, same signal decoding. The key difference is what happens after decoding:
| Dashboard | Presenter |
|---|---|
| Renders events immediately as they arrive | Collects events into per-agent buckets, then plays scene-by-scene |
| All agents visible simultaneously | One agent at a time, with zoom focus |
| No playback control | Pause, step, speed, director mode |
userId: dashboard-observer |
userId: presenter-observer |
The compact key map (k → kind, s → sender, p → payload) is shared
with the dashboard and matches bedsheet/sense/serialization.py.
Lessons Learned
This section documents bugs found during code review and the patterns behind them. These are common JavaScript pitfalls that apply broadly, not just to this presenter.
Always Handle the Error Path
Bug: handleStatus only handled PNConnectedCategory.
When PubNub returned an error (bad key, network down, timeout), the user saw "Connecting..."
forever with no feedback.
Pattern: Every async connection (PubNub, WebSocket, fetch, etc.) has two outcomes: success and failure. If you only handle success, every failure becomes a silent hang. The fix is to handle known error categories explicitly and add a catch-all for unknown errors:
function handleStatus(statusEvent) {
if (statusEvent.category === 'PNConnectedCategory') {
// Happy path
} else if (statusEvent.category === 'PNAccessDeniedCategory') {
showError('Access denied');
} else if (statusEvent.category === 'PNNetworkDownCategory') {
showError('Network error');
} else if (statusEvent.error) {
// Catch-all: log the category for diagnostics
console.error('Unknown error:', statusEvent.category);
showError('Failed: ' + statusEvent.category);
}
}
Rule: If a function receives a status/result object, ask: "What categories can this be?" and handle each one. If you can't enumerate them, add a catch-all that at least logs and surfaces the category name.
Never Use Empty Catch Blocks
Bug: catch(e) {} in animateLinesFromToolCall
silently swallowed JSON parse errors, null reference errors, and SVG manipulation failures.
Signal lines between agents failed invisibly during demos.
Pattern: catch(e) {} is never acceptable. It suppresses
ALL errors, not just the one you intended to handle. Even if you think the error is
harmless, you lose all diagnostic information when something unexpected goes wrong.
// BAD: every error vanishes into the void
try { riskyCode(); } catch(e) {}
// GOOD: at minimum, log with context
try { riskyCode(); } catch(e) {
console.warn('[context] Failed:', e.message);
}
// BETTER: handle expected errors, re-throw unexpected ones
try { JSON.parse(input); } catch(e) {
if (e instanceof SyntaxError) {
console.warn('Malformed input:', input);
} else {
throw e; // unexpected error, don't swallow
}
}
Rule: Every catch block needs at least a console.warn
with enough context to diagnose the problem. The comment "// ignore" is not a valid reason
to suppress errors — write what you're ignoring and why.
Initialization Order Between Async Systems
Bug: drainMapEvents was called 3 seconds after PubNub connected,
but playbackRunning was only set to true after the 8-second
collection window. So the drain function ran once, saw playbackRunning === false,
and exited forever. Map animations were completely dead.
Pattern: When system A depends on system B's state, don't start A alongside B and hope the timing works. Start A from within B, at the point where the dependency is guaranteed to be satisfied:
// BAD: race condition — drainMapEvents might run before
// playbackRunning is true
connectToPubNub();
setTimeout(drainMapEvents, 3000); // might run too early
setTimeout(startScenePlayback, 8000); // sets playbackRunning
// GOOD: start the dependent function from where its
// precondition is guaranteed
function startScenePlayback() {
playbackRunning = true;
drainMapEvents(); // now playbackRunning is definitely true
beginScene();
}
Rule: If function A checks a flag that function B sets, start A from inside B, after the flag is set. Never rely on timeout ordering.
Use else-if for Mutually Exclusive Conditions
Bug: A quarantine signal has kind: 'alert', severity: 'critical',
AND action: 'quarantine'. Two separate if blocks both matched:
first "Sentinel Alert" was shown, then immediately overwritten by "Quarantine Issued".
The audience never saw the first card.
Pattern: When multiple conditions can match the same input, and only
one should fire, use else if. Put the more specific condition first:
// BAD: both conditions fire on the same signal
if (kind === 'alert' && severity === 'critical') {
showChapter('Sentinel Alert');
}
if (kind === 'alert' && action === 'quarantine') {
showChapter('Quarantine'); // immediately overwrites above
}
// GOOD: quarantine is more specific, check it first
if (kind === 'alert') {
if (action === 'quarantine') {
showChapter('Quarantine');
} else if (severity === 'critical') {
showChapter('Sentinel Alert');
}
}
Rule: When two conditions can both match the same data, ask: "Should
both fire, or only one?" If only one, use else if with the more specific
condition first.
Guard External Dependencies
Bug: PubNub SDK loaded from a CDN. If the CDN is blocked (firewall,
DNS, outage), new PubNub() throws ReferenceError: PubNub is not defined.
The try/catch caught it, but the error message was cryptic.
Pattern: Before using a global that comes from an external <script>,
check if it loaded. Give a clear, actionable error message:
// Before using any CDN-loaded library
if (typeof PubNub === 'undefined') {
showError('PubNub SDK failed to load. Check network or firewall.');
return;
}
// Same pattern for any external dependency
if (typeof Chart === 'undefined') { ... }
if (typeof hljs === 'undefined') { ... }
Rule: typeof X === 'undefined' before the first use of any
global from a CDN. The error message should name the library and suggest a cause
(network, firewall) — not just say "X is not defined".
Handle Promise Rejections
Bug: requestFullscreen() returns a Promise that can reject
(browser policy, iframe restrictions). The rejection was unhandled, producing a console warning.
Pattern: Browser APIs that return Promises (fullscreen, clipboard,
notifications, audio play) can reject for policy reasons. Always add .catch():
// BAD: unhandled rejection warning in console
document.documentElement.requestFullscreen();
// GOOD: catch and log
document.documentElement.requestFullscreen().catch(function(err) {
console.warn('Fullscreen denied:', err.message);
});
// Same for audio, clipboard, notifications...
audio.play().catch(function(err) {
if (err.name !== 'NotAllowedError') { // expected for autoplay
console.warn('Audio failed:', err.name);
}
});
Rule: Every .then() needs a .catch(), and every
await needs a try/catch. For browser APIs, filter expected
rejections (like NotAllowedError for autoplay) from unexpected ones.
Architectural Pivot: Reactive to Scene-Based
The biggest change during development was moving from reactive playback (show events as they arrive from PubNub) to scene-based playback (collect events, group by agent, play one agent at a time).
Why the pivot: PubNub delivers events from all 7 agents interleaved. In reactive mode, focus thrashed between agents every few hundred milliseconds — the map zoomed to agent A, then immediately to agent B, then back. Unwatchable.
The fix: Collect all events for 8 seconds into per-agent buckets
(agentScenes), then play them sequentially. Each agent gets an uninterrupted
"scene" with full focus.
Lesson: When you change the architecture, audit ALL code that depended
on the old model. The drainMapEvents timing bug existed because it was
wired up during the reactive phase and never re-wired after the pivot. Every architectural
change leaves orphaned assumptions — grep for them.
Files
| File | Purpose |
|---|---|
docs/sentinel-presenter.html |
The presenter page (CSS + HTML + JS, self-contained) |
docs/sentinel-presenter-guide.html |
This guide |
docs/agent-sentinel-dashboard.html |
The original monitoring dashboard (unchanged) |
docs/world-map.svg |
World map background (shared by both pages) |
examples/agent-sentinel/start.sh |
Launch script with --present and --cinematic flags |
examples/agent-sentinel/recordings/*.jsonl |
Pre-recorded LLM interactions per agent |
Troubleshooting
Presenter shows "Disconnected" / no events
- Check that
.envhas valid PubNub keys - Check that
sentinel-config.jswas generated (look indocs/) - Verify agents are actually running:
tail -f /tmp/sentinel-*.log
Map doesn't show / blank page
- Ensure
world-map.svgexists indocs/ - Open browser console for errors
- Try a hard refresh (Cmd+Shift+R)
Events arrive but no focus zoom
- Focus zoom only triggers for LLM events (PubNub signal kind =
event) - Heartbeats and other signals animate on the map but don't change focus
- Check browser console for JavaScript errors
Keyboard shortcuts don't work
- Click somewhere on the page first (not in the PubNub key input field)
- Keyboard events are suppressed when the input field is focused
Recording playback too fast / too slow
- The event buffer decouples agent speed from presentation speed
- Use speed keys 1-5 to adjust in real-time
- Or change the base replay delay:
./start.sh --replay 0.5 --present