I've been spending a lot of time lately working with coding agents across multiple processes. An agent refactoring code in one project, another running tests in a second, a build pipeline doing its thing, doctrove 1 syncing documentation in the background. The problem is visibility. Each of these runs in its own terminal or process, and when something goes wrong or you want to understand what's happening across the system, you're tabbing between windows, tailing logs, and piecing together a timeline in your head.
I wanted a single place to watch everything. Not a full observability stack with Prometheus and Grafana and a time-series database. Something I could leave running that would show me events from any process on my system as they happen.
That's eventrelay 2.
What it is
A single Go binary. Start it, open a browser, and anything on your system that can POST JSON has a live dashboard. Events flow in, the dashboard shows them in real time.
eventrelay --port 6060
From any script, any process, any language:
curl -X POST http://localhost:6060/events \
-d '{"source":"myapp","action":"deploy","data":{"env":"prod"}}'
That event appears in the browser immediately. It stays in a ring buffer (default 1000 events) so you can scroll back. The dashboard color-codes by level, groups by channel, and lets you filter by source, action, or any combination.
The whole thing is ephemeral by design. Events live in memory. When the buffer fills, old events drop off. This is deliberate. eventrelay is a window into what's happening now, not a storage system. If you need persistence, there's a JSONL file option and a database notification target, but the default is zero disk usage.
Where it came from
I built this while developing doctrove 1, a documentation store for AI agents. doctrove exposes 20 MCP tools 3 that agents call during their work, things like search, read, discover, summarize. I needed to see what the agent was actually doing. What was it searching for? What results was it getting? Was it reading entire files when it should have been reading sections?
So I added event emission to doctrove and built a dashboard to watch it. That dashboard became eventrelay.
Once I had it running, I started piping events from everything. Build scripts emit start/complete events with duration. Test runners report pass/fail. Background processes report their status. The agent's research process stopped being a black box and became something I could watch and steer in real time.
The event schema
Events are simple JSON. One required field: source. Everything else is optional.
{
"source": "doctrove",
"channel": "mcp",
"action": "trove_search",
"level": "info",
"agent_id": "myproject:00a3f1",
"duration_ms": 42,
"data": {"query": "authentication", "site": "stripe.com"}
}
source is the primary grouping key in the dashboard. channel separates concerns within a source. action is what happened. level is severity. duration_ms gets displayed inline so you can spot slow operations. data carries whatever structured context makes sense.
The schema is intentionally flat. No nested hierarchies, no trace IDs, no span trees. This is for development visibility, not distributed tracing. You want to glance at a dashboard and understand what's happening across your system. Flat events with consistent field names make that work.
Fire-and-forget SDKs
SDKs exist for Go, Python, and TypeScript. They all follow the same principle: never block the caller's critical path. Events are sent asynchronously. If the server is down, events are silently dropped. The caller never waits, never retries, never gets an error.
c := client.New("http://localhost:6060/events", "myapp")
c.Emit("deploy", map[string]any{"env": "prod"})
er = Client("http://localhost:6060/events", "myapp")
er.emit("deploy", {"env": "prod"})
If the URL is empty, the client becomes a no-op. Zero overhead. This means you can leave instrumentation in your code and toggle it by presence or absence of a URL in your config.
The Go SDK also implements slog.Handler 4, so you can wire your existing structured logging directly into eventrelay:
handler := client.NewSlogHandler(c, "logs")
logger := slog.New(handler)
logger.Info("request handled", "path", "/api/users", "status", 200)
Those log entries show up on the Logs tab of the dashboard with level filtering, rate graphs, and the same real-time stream as events.
Log sink
The structured logging endpoint (POST /log) is separate from events by design. Events represent things that happened. Logs represent diagnostic output. They have different volumes, different retention needs, and different filtering patterns.
eventrelay maintains a separate ring buffer for logs with level-based gating. Configure log_min_level: warn and debug/info logs are dropped at the server before they hit the buffer. Useful when you want verbose logging in development but only care about errors during a demo.
Both events and logs get their own SSE streams 5, their own rate graphs, and their own tabs in the dashboard. Same infrastructure, separate concerns.
Pages: command portal
This is the feature that turned eventrelay from a development tool into something I leave running as a system service.
You register shell commands in the config. Each command becomes a tab in the dashboard. The command runs on demand (or on a cache interval), and its output is rendered in the browser.
pages:
- name: System
command: er-system
format: markdown
interval: 30s
- name: Ports
command: er-ports
format: text
interval: 10s
The er-system script is a 40-line shell script that outputs markdown. Hostname, OS version, CPU, memory, disk, load averages, top processes. eventrelay renders it as a formatted page in the dashboard.
er-ports runs lsof to show listening TCP ports. er-services shows running launchd agents. er-brew shows Homebrew package status. All shell scripts. All rendered in the same interface as the event stream.
The output formats handle the rendering: text gets monospace pre-formatting, json gets syntax highlighting, markdown gets full rendering with headings, tables, code blocks. Write a script that outputs whatever format you want, point eventrelay at it, and you have a dashboard tab.
Commands are config-only. There's no API for adding commands at runtime. All output is HTML-escaped before rendering. This is a deliberate security boundary. The person who writes the config controls what runs.
I have eventrelay running as a launchd service on my Mac. It starts on login and stays running. The browser dashboard gives me system overview pages alongside whatever development events are flowing. When I'm deep in a multi-process development session, I have one browser tab open to localhost:6060 and I can see everything.
Notification routing
Match rules in the config forward events to external services. Slack webhooks, Discord webhooks, generic HTTP endpoints, or a SQLite database for archival.
notify:
- name: errors to slack
match:
level: error
slack:
webhook_url: https://hooks.slack.com/services/...
- name: deploys to discord
match:
source: ci
action: deploy
discord:
webhook_url: https://discord.com/api/webhooks/...
Match rules use AND logic. All non-empty fields must match. So level: error matches all errors from any source. level: error, source: myapp matches only errors from myapp. Notifications fire asynchronously and never block event processing.
This is lightweight alerting. Not PagerDuty. If your build fails and you want a Slack message, add a match rule. If you want to archive all error events to a database, add a database target. The event keeps flowing to the dashboard either way.
The architecture
The core is a ring buffer with a fan-out broadcaster. Events arrive via HTTP POST, get assigned a sequence number, enter the ring buffer, and fan out to SSE subscribers 5. Each SSE client gets its own buffered channel. If a client is slow, it drops events instead of applying backpressure to the server. The write lock is released before fan-out so slow clients never block event ingestion.
The web dashboard is vanilla HTML, CSS, and JavaScript, embedded in the binary. No build step, no npm, no webpack. It connects to the SSE endpoint and renders events as they arrive. Filtering happens client-side so the SSE stream is uninterrupted.
There's also a TUI dashboard built with bubbletea 6 for terminal use. Same data, rendered in the terminal with live filtering and color coding. Useful for SSH sessions or when you prefer terminal over browser.
The whole server is about 2000 lines of Go. No framework. Standard library HTTP server. The only dependencies are bubbletea 6 for the TUI, modernc.org/sqlite for the optional database target, and yaml.v3 for config parsing.
How I actually use it
My daily setup: eventrelay is running as a system service. The browser dashboard is open. I have pages configured for system overview, listening ports, and running services.
When I start working with coding agents, events flow in automatically. doctrove 1 emits events for every MCP tool call 3, showing me what the agent is searching for and what it finds. Other tools I've instrumented emit their own events. The dashboard becomes a heads-up display for the entire development session.
When something looks wrong, I can see it immediately. The agent searching for the wrong thing? Visible. A build step taking too long? The duration is right there. An error in a background process? Red badge in the event stream.
The pages system fills in the gaps. I can check what ports are in use, what services are running, whether the system is under load, without leaving the dashboard or opening another terminal.
The whole thing is intentionally lightweight. No persistence unless you configure it. No external services unless you add notification rules. Start a binary, get a dashboard. That's the entire operational burden.
Getting started
go install github.com/dmoose/eventrelay@latest
eventrelay --port 6060
Open http://localhost:6060. Send an event. Everything after that is optional.
For a system service on macOS:
make install
make install-service
eventrelay starts on login, auto-restarts on crash, and keeps running until you stop it.
The project is MIT licensed and on GitHub at github.com/dmoose/eventrelay 2.