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.

Key Difference from Dashboard The dashboard is CCTV footage. The presenter is a documentary shot in the same location with deliberate framing, pacing, and narrative structure.

Quick Start

Prerequisites

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
Tip Press C to toggle between modes at any time. You can start in present mode for setup, then switch to cinematic before recording.

Launch Commands

CommandEffect
./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)
Note --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

KeyAction
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

KeyAction
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

  1. Launch with --present (keep UI chrome visible)
  2. Start paused (press Space)
  3. Use to step through events as you narrate
  4. Use Shift+ to jump to the next agent when ready
  5. Shift+1-5 to set speed, or bare 1-9 to jump to a specific agent's scene

For Screen Recordings

  1. Launch with --present --cinematic
  2. Press F for fullscreen
  3. Start Screen Studio recording
  4. Press Space to begin auto-play
  5. Use Shift+1-5 for speed (Shift+2 = 1x). Bare number keys jump to agent scenes.
  6. The presenter handles focus transitions and chapter cards automatically
Pro tip The event buffer decouples presentation speed from agent replay speed. Even if agents emit events quickly, the presenter releases them at a controlled pace. This means you can use a fast --replay 0.1 to fill the buffer quickly, then present at a comfortable speed.

Data Flow

start.sh --present | +-- Launches 7 agent processes in replay mode | +-- Each replays LLM responses from recordings/*.jsonl | +-- Each publishes LLM events to PubNub (_publish_llm_event) | +-- BEDSHEET_REPLAY_DELAY controls base agent pacing | +-- Starts HTTP server on port 8765 | +-- Opens sentinel-presenter.html +-- Connects to PubNub (same subscribe key) +-- Receives LLM events per agent +-- Buffers events in eventBuffer[] +-- Drain loop releases events to UI at controlled pace +-- Auto-zooms map to active agent +-- Shows focus overlay with event cards

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.

ChapterTrigger
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:

  1. Smoothly zooms the map to 2.5x, centered on the agent's geographic position (0.8s transition)
  2. Dims all other agent nodes to 20% opacity
  3. Adds a glow effect to the focused agent's node
  4. Shows a focus overlay panel to the right of the agent with event cards streaming in
  5. 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

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

  1. Open the presenter URL in Chrome
  2. Press F for fullscreen
  3. Press Space to pause immediately
  4. Start Screen Studio
  5. Press Space to begin the demo

Step 4: During recording

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.

Tip Start paused (Space), then use number keys to jump between agents in whatever order tells the best story. You're the director.

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:

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:

ChapterTrigger
Network StartupPubNub connection
Skill AcquisitionFirst tool call from skill-acquirer
Malicious Install BlockedBlocked malicious package result
Rogue Burst3+ consecutive tool_calls from one agent
Gateway Block"Rate exceeded" or "Action denied" in result
Sentinel AlertCritical/high alert from a sentinel
Quarantine IssuedCommander 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:

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:

  1. PubNub signals: request/response/alert signals from the event buffer animate lines between sender and target (same as the dashboard).
  2. LLM tool calls: when an agent's scene shows a tool_call event, 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.

KeyAction
N or Next chapter — advance when you're ready for the next beat
P or Previous chapter — go back one
19Jump to chapter N — cancels pending cues, resets visuals, plays target chapter from t=0
RRestart movie from chapter 0
Shift+15Speed: 0.5x / 1x / 1.5x / 2x / 3x (re-paces the current chapter's remaining cues)
FFullscreen
EToggle panel-edit mode — drag panels to author positions (see next section)
XExport panel positions to console + clipboard (paste into source to lock)
CIn 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:

  1. Open the movie: ./start.sh --movie
  2. 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)
  3. 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.
  4. Drag the focus overlay and briefing overlay to the positions you want for the currently-spotlighted agent.
  5. Release — the console logs [panel-edit] saved <agent> <which> <x> <y>. Position is persisted to localStorage.
  6. Press N to advance to the next chapter. When the next agent is spotlighted, drag its panels into place.
  7. Repeat until every agent's panels are where you want them.
  8. Press X — full PANEL_POSITIONS JS snippet printed to console and copied to clipboard.
  9. Paste the snippet into docs/sentinel-presenter.html to replace the existing var PANEL_POSITIONS = {}; declaration. Commit.

Persistence:

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.

  1. Chapter 0 — Bedsheet Brief — pitch types out on its own (~37s). Press N when done reading.
  2. Chapter 1 — Architecture — two-plane diagram draws in with a 3-line caption. Stays until you press N.
  3. Chapter 2 — Network Startup — 7 agents come online.
  4. Chapter 3 — Normal Operations — routine tool calls through the gateway.
  5. Chapter 4 — Malicious Install Blocked — supply-chain sentinel catches SHA-256 mismatch.
  6. Chapter 5 — Rogue Burst — web-researcher fires 5 tool calls in 2s.
  7. Chapter 6 — Gateway Block — deterministic rate limiter responds.
  8. Chapter 7 — Sentinel Alert — behavior-sentinel correlates the spike to commander.
  9. Chapter 8 — Quarantine Issued — commander quarantines web-researcher.
  10. 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 18 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

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
Why collect first? PubNub delivers events from all agents interleaved. Without collection, focus would thrash between agents every few hundred milliseconds. By collecting into buckets first, each agent gets an uninterrupted scene.

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:

DashboardPresenter
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

FilePurpose
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

Map doesn't show / blank page

Events arrive but no focus zoom

Keyboard shortcuts don't work

Recording playback too fast / too slow