Building Hermes Mission Control: Real-Time Visual Observability for Agent Swarms
How we built a cinematic, real-time command center for Hermes agent systems using Next.js, React Flow, and Server-Sent Events. From the data model to the live agent graph, here's the full architecture.
Jeff
Windows & Microsoft Ecosystem
Introduction
When you're running a swarm of autonomous AI agents, the hardest question isn't "can it work?" — it's "what is it doing right now?"
In traditional software, you have logs, metrics, and dashboards. In agent systems, you have a black box making decisions, calling tools, evolving skills, and communicating with other agents — all at a pace and complexity that's nearly impossible to track through log files alone.
Hermes Mission Control is our answer to that problem. It's a real-time, visual command center that makes the invisible work of agent swarms visible, inspectable, and steerable. This post walks through the complete architecture, data model, and implementation details — and shows you how to build your own.
The Problem: Agent Observability Gap
Consider a typical Hermes deployment:
- 8-12 active agents working in parallel across different domains (coding, research, testing, orchestration)
- 50+ skills that are continuously created, improved, branched, and deprecated
- Thousands of tool calls per hour, with complex dependency chains
- Real-time memory updates as agents learn and share knowledge
- Dynamic task allocation where agents pick up, complete, and spawn new tasks
Traditional observability tools — Prometheus dashboards, log aggregators, tracing systems — are designed for deterministic, request-response systems. They break down when applied to agent swarms because:
- Non-linear causality: An agent's decision may depend on memory written 10 minutes ago by a different agent
- Emergent behavior: The system's overall behavior can't be predicted from individual agent behavior
- Continuous evolution: Skills and strategies change over time, making static dashboards obsolete
- Human-in-the-loop: Operators need to understand, intervene, and guide — not just monitor
Architecture Overview
Mission Control follows a local-first, real-time architecture:
┌─────────────────────────────────────────────────────────┐
│ Browser (Next.js) │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
│ │ Agent Graph │ │ Skill Tree │ │ Activity Feed │ │
│ │ (React Flow)│ │ (Timeline) │ │ (SSE Stream) │ │
│ └─────────────┘ └─────────────┘ └─────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐│
│ │ System Metrics ││
│ │ (Active agents, success rates, resource usage) ││
│ └─────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────┘
│ │
▼ ▼
┌─────────────────────┐ ┌──────────────────────┐
│ REST API Routes │ │ SSE Event Stream │
│ (Next.js API) │ │ (Real-time updates) │
└─────────────────────┘ └──────────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────┐
│ Mock Data Store │
│ (Event emitter pattern for real-time simulation) │
│ (Replaceable with live Hermes instance connection) │
└─────────────────────────────────────────────────────────┘
Tech Stack
| Layer | Technology | Why |
|---|---|---|
| Framework | Next.js 16 (App Router) | SSR, API routes, deployment simplicity |
| Visualization | React Flow | Production-grade graph rendering, zoom, pan |
| Real-time | Server-Sent Events (SSE) | Simpler than WebSockets, HTTP-compatible, auto-reconnect |
| Styling | Tailwind CSS | Rapid iteration, consistent design system |
| Icons | lucide-react | Clean, consistent iconography |
| Type Safety | TypeScript | Critical for data model integrity |
Data Model: The Foundation
The heart of Mission Control is its data model. Everything — the graph, the timeline, the metrics — flows from these interfaces:
interface Agent {
id: string;
name: string;
type: 'orchestrator' | 'worker' | 'researcher' | 'coder';
status: 'idle' | 'thinking' | 'tool_calling' | 'success' | 'failed' | 'evolving';
currentTask: string | null;
skills: string[];
createdAt: string;
lastActive: string;
}
interface Skill {
id: string;
name: string;
description: string;
version: string;
author: 'agent' | 'human';
status: 'active' | 'evolving' | 'deprecated';
performance: {
usageCount: number;
successRate: number;
avgExecutionTime: number;
};
versions: SkillVersion[];
}
interface SystemEvent {
id: string;
timestamp: string;
type: 'agent_started' | 'agent_completed' | 'skill_created' |
'skill_evolved' | 'task_started' | 'task_completed' |
'tool_called' | 'error' | 'goal_injected';
agentId: string | null;
skillId: string | null;
taskId: string | null;
message: string;
details: Record<string, any>;
}
interface GraphNode {
id: string;
type: 'agent' | 'skill';
name: string;
status: string;
agentType?: string;
description?: string;
}
interface GraphEdge {
id: string;
source: string;
target: string;
type: 'communication' | 'tool_use' | 'memory_access' | 'skill_inheritance';
label?: string;
}
Key Design Decisions
Separation of concerns: Agents, skills, tasks, memory, and events are separate entities with their own lifecycles. This makes the system extensible — you can add new entity types without breaking existing code.
Graph as a derived view: The
SkillGraph(nodes + edges) is generated from the agent/skill data, not stored independently. This ensures consistency — if an agent is deleted, its graph nodes are automatically removed.Event sourcing pattern: All state changes are recorded as events. This enables time-travel debugging, audit trails, and the activity feed.
Versioned skills: Skills have a
versionfield and can have multipleversionsin their history. This supports the evolution timeline feature.
Real-Time Updates: SSE Over WebSockets
We chose Server-Sent Events (SSE) over WebSockets for real-time updates. Here's why:
- Simpler implementation: SSE is just HTTP with a specific content type. No connection handshake, no message framing.
- Automatic reconnection: Browsers automatically reconnect to SSE endpoints if the connection drops.
- HTTP-compatible: Works with existing infrastructure (proxies, CDNs, load balancers).
- Unidirectional: For Mission Control, the server only needs to push data to the client. Bidirectional communication isn't needed.
The SSE endpoint is a Next.js API route:
// src/app/api/events/stream/route.ts
export async function GET(request: NextRequest) {
const stream = new ReadableStream({
start(controller) {
const send = (data: any) => {
controller.enqueue(`data: ${JSON.stringify(data)}\n\n`);
};
// Send initial state
send({ type: "init", data: getSystemState() });
// Subscribe to state changes via event emitter
const unsubscribe = subscribeToState((state) => {
send({ type: "update", data: state });
});
// Heartbeat every 15 seconds
const heartbeat = setInterval(() => {
send({ type: "heartbeat", timestamp: Date.now() });
}, 15000);
// Cleanup on disconnect
let cleanedUp = false;
request.signal.addEventListener("abort", () => {
if (cleanedUp) return;
cleanedUp = true;
unsubscribe();
clearInterval(heartbeat);
try { controller.close(); } catch (e) { /* already closed */ }
});
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
});
}
On the client side, the React component connects via EventSource:
useEffect(() => {
const es = new EventSource("/api/events/stream");
es.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === "update" && msg.data) {
setState(msg.data);
}
};
return () => es.close();
}, []);
Activity Simulation
For development and demos, we simulate real-time agent activity:
export function startActivitySimulation(intervalMs: number = 3000): void {
activityInterval = setInterval(() => {
const skill = skills[Math.floor(Math.random() * skills.length)];
const agent = agents[Math.floor(Math.random() * agents.length)];
// Randomly update agent status
agent.status = statuses[Math.floor(Math.random() * statuses.length)];
agent.lastActive = new Date().toISOString();
// Generate event
const event: SystemEvent = {
id: uuidv4(),
timestamp: new Date().toISOString(),
type: eventTypes[Math.floor(Math.random() * eventTypes.length)],
agentId: agent.id,
message: `Agent ${agent.name} ${type.replace("_", " ")}`,
details: { duration: Math.floor(Math.random() * 5000), success: Math.random() > 0.2 },
};
store.events.unshift(event);
broadcastChange();
}, intervalMs);
}
The Live Agent Graph
The centerpiece of Mission Control is the interactive agent graph, built with React Flow. Here's how it works:
Custom Node Components
Each node type has a custom renderer:
function AgentNode({ data }: { data: { label: string; status: string; type: string } }) {
const statusColors = {
idle: "border-slate-500",
thinking: "border-blue-400",
tool_calling: "border-amber-400",
success: "border-green-400",
failed: "border-red-400",
evolving: "border-purple-400",
};
const typeIcons = {
orchestrator: Users,
worker: Bot,
researcher: Database,
coder: Code,
};
return (
<div className={`bg-slate-800 border-2 ${borderColor} rounded-xl p-3 min-w-[120px] text-center`}>
<Handle type="target" position={Position.Top} />
<Handle type="source" position={Position.Bottom} />
<div className="flex items-center justify-center mb-1">
<Icon className="w-4 h-4 text-blue-400" />
</div>
<div className="text-white text-sm font-medium">{data.label}</div>
<div className="text-slate-400 text-xs capitalize">{data.status}</div>
<div className="text-slate-500 text-xs">{data.type}</div>
</div>
);
}
Edge Types and Color Coding
Edges represent different types of relationships:
- Communication (blue): Agent-to-agent communication
- Tool use (amber): Agent using a skill/tool
- Memory access (purple): Agent accessing shared memory
- Skill inheritance (green): Agent inherits from a skill
Circular Layout
Nodes are positioned in a circle for initial rendering:
useEffect(() => {
if (nodes.length === 0) return;
const centerX = 400;
const centerY = 300;
const radius = 200;
const nodeCount = nodes.length;
const updatedNodes = nodes.map((node, index) => {
const angle = (2 * Math.PI * index) / nodeCount;
return {
...node,
position: {
x: centerX + radius * Math.cos(angle) - 60,
y: centerY + radius * Math.sin(angle) - 30,
},
};
});
setNodes(updatedNodes);
}, [graph, agents, skills]);
Goal Injection: Steering the System
One of the most powerful features is the ability to inject new goals from the UI:
const handleInjectGoal = async () => {
if (!goalInput.trim()) return;
setInjecting(true);
try {
await fetch("/api/goals", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ goal: goalInput }),
});
setGoalInput("");
} finally {
setInjecting(false);
}
};
The goal is added as a new task and an goal_injected event appears in the activity feed. In a production system, this would trigger the agent system to pick up the new task.
Extending Mission Control
Connecting to a Live Hermes Instance
The current implementation uses mock data. To connect to a live Hermes instance:
- Replace the mock data store with a Hermes API client:
// Replace getSystemState() with:
export async function getSystemState(): Promise<SystemState> {
const [agents, skills, tasks, events] = await Promise.all([
hermesClient.getAgents(),
hermesClient.getSkills(),
hermesClient.getTasks(),
hermesClient.getEvents(),
]);
return {
agents,
skills,
tasks,
events,
graph: generateGraph(agents, skills),
metrics: calculateMetrics(agents, skills, tasks, events),
};
}
- Update the SSE endpoint to poll the Hermes API instead of simulating activity:
// In the SSE endpoint, replace startActivitySimulation with:
const pollInterval = setInterval(async () => {
const state = await getSystemState();
send({ type: "update", data: state });
}, 5000);
- Add authentication to the API routes:
export async function GET(request: NextRequest) {
const token = request.headers.get("Authorization");
if (!token || !validateToken(token)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// ... rest of handler
}
Adding New Visualization Types
The modular component structure makes it easy to add new visualizations:
// Add a new component: src/lib/components/MemoryMap.tsx
export default function MemoryMap({ memory }: { memory: MemoryEntry[] }) {
// Visualize memory as a force-directed graph
// Nodes = memory entries, edges = references between entries
}
// Add to the dashboard:
<section className="mb-8">
<h2 className="text-lg font-semibold text-white mb-4">Memory Map</h2>
<MemoryMap memory={state.memory} />
</section>
Custom Edge Types
React Flow supports custom edge types for specialized visualizations:
const edgeTypes: EdgeTypes = {
dependency: DependencyEdge,
dataFlow: DataFlowEdge,
causal: CausalEdge,
};
// Use in the graph:
<ReactFlow
edgeTypes={edgeTypes}
// ...
/>
Performance Considerations
For large agent swarms (100+ agents), consider:
- Virtualized rendering: Use
react-windowor similar for large lists - Clustering: Group agents by domain or status in the graph
- Sampling: Only show events from the last N minutes in the feed
- Web Workers: Offload graph layout calculations to a background thread
Conclusion
Hermes Mission Control demonstrates that agent observability doesn't have to be complex. By combining:
- A clean, versioned data model
- Real-time updates via SSE
- Interactive visualizations with React Flow
- A local-first architecture
...we've built a tool that makes the invisible work of agent swarms visible and actionable.
The full source code is available at github.com/smfworks/hermes-mission-control. Whether you're running Hermes, OpenClaw, or your own agent framework, the patterns and architecture described here can be adapted to your needs.
Next in this series: Hermes Skill Forge: A Visual Studio for Skill Evolution — how we built a tool for inspecting, editing, and steering the self-improvement of Hermes skills.