← Back to home

Case study

AgentBeacon

What is my AI agent doing right now, and does it need me?

Role
Design & build (solo)
Stack
TypeScript · Node.js · MCP · n8n
Status
Phase 1 shipped, in production on a self-hosted Raspberry Pi 5

The problem

I run Claude Code and OpenAI Codex across several machines: a couple of MacBooks and a Mac Studio. The agents are good enough to work for long stretches without me, and that is exactly the problem. When one of them stops to ask for a permission or a decision, nothing tells me. I find out when I go back to the terminal, sometimes an hour later.

I didn't want a dashboard to stare at, and I didn't want a notification for every file an agent reads. I wanted one signal, only when it matters: an agent is blocked on me, it finished, or it failed.

The solution

Agents report a normalized status through a single MCP tool, agentbeacon_set_status. AgentBeacon validates the input with zod, normalizes it into one event shape, deduplicates it and POSTs it to one generic webhook. That webhook is a self-hosted n8n workflow, and n8n delivers the message to Telegram.

AgentBeacon knows nothing about Telegram. Adding Discord, email or a history log is a change in n8n, not a code release.

Architecture

The event contract is the boundary everything else hangs from. Agents write to it, providers read from it, and neither side knows the other exists.

AgentBeacon architecture. An AI agent (Claude Code or Codex) calls the MCP tool. The event is validated and normalized, then passed to the dispatcher, which sends it to the webhook provider, then to n8n, then to Telegram. Philips Hue lights are a planned second provider for Phase 2, fed by the same dispatcher.

The one rule I don't bend

src/core never imports from src/providers. Every provider implements the same small interface, { name, isEnabled(), accepts?(event), handle(event) }, and that interface is all the core ever sees.

Status model

Five statuses and no more. Every time I was tempted to add idle, paused or cancelled, there wasn't a concrete need behind it.

  • working Silent

    Active work: reading, writing, running tests or a build.

  • waiting Silent

    Waiting on something external that isn't me, like a deploy.

  • needs_attention Notifies

    Can't continue without me: a permission, a decision, missing info.

  • completed Notifies

    The task finished successfully.

  • failed Notifies

    Couldn't finish, with the reason when there is one.

By default only needs_attention, completed and failed send a notification. Each one can be switched on or off through environment variables.

Key decisions and trade-offs

  1. Identity is machine + agent + session

    An execution is keyed as MB16:codex:s1. Without the machine in the key, two machines reporting with the sessionId default landed on the same entry: one overwrote the other, and dedup silenced the second machine's notifications. It's the kind of bug a single-machine test never shows you.

  2. The client never picks its machine alias

    The alias is derived on the server from the bearer token. If the request body carries a machine field, it's discarded. A machine can't impersonate another one, and a typo in a config file can't split one machine into two. Tokens are compared in constant time, and HTTP mode refuses to start without any tokens configured.

  3. Dedup only arms after a successful delivery

    Identical repeated statuses produce one notification, but the dedup entry is only recorded after a delivery succeeds. If every provider failed, publishing the same status again retries instead of getting swallowed. Losing a needs_attention is the worst failure this system can have.

  4. Provider failures stay isolated

    Fan-out uses Promise.allSettled. A failing provider never crashes the process and never fails the agent's task. The webhook provider retries a bounded number of times (3 by default, 5 max) with exponential backoff starting at 250 ms and an 8 s timeout. It only retries network errors, 429 and 5xx. A 404 means the n8n path is wrong, and retrying won't fix that.

  5. I deleted the Telegram provider

    There used to be a direct Telegram provider. It meant a bot token and a chat id living in the codebase, and a release every time I wanted a new channel. I removed it and handed delivery to n8n. AgentBeacon now does one thing: emit a clean event to one webhook.

  6. Secrets never reach the logs

    The logger redacts Bearer credentials, authorization fields and anything shaped like a Telegram bot token. In stdio mode logs go to stderr, because stdout is the MCP protocol channel and a stray log line there breaks the session.

Deployment

Production is one shared instance that every machine points to. It runs the HTTP transport: MCP Streamable HTTP for the agents, a REST POST /events endpoint for scripts, and a public /healthz for checks.

  • Multi-stage Docker build on node:24-alpine, arm64, running as a non-root user.
  • Deployed with Dokploy on a Raspberry Pi 5 at home.
  • Exposed through Cloudflare Tunnel, so there is no inbound port open on my network.

The trade-off I accepted

Exactly one replica, and state lives in memory, so it resets on every redeploy. For Phase 1 I'm fine with that. No database and no queue, by design, until a phase actually needs them.

By the numbers

  • 238

    tests passing

    node:test in ~250 ms, no network, no credentials. fetch is injected.

  • ≈1.6×

    more test code than source

    ~2,550 LOC of tests vs ~1,570 LOC of source.

  • 2

    runtime dependencies

    @modelcontextprotocol/sdk and zod. Everything else is Node.

  • 1

    MCP tool

    One tool with a status field, not one tool per status.

  • 5

    statuses

    working, waiting, needs_attention, completed, failed.

Stack

  • TypeScript (strict, ESM)
  • Node.js
  • MCP SDK
  • zod
  • native fetch
  • node:test
  • Docker
  • Dokploy
  • Cloudflare Tunnel
  • n8n
  • Telegram

What's next

  1. Phase 2

    Philips Hue lights

    A color per status, so I can tell from across the room that an agent needs me.

  2. Phase 4

    ESP32 physical display

    A small device on the desk that shows what each active session is doing.

  3. Phase 5

    Dashboard and history

    The first phase that needs a database, so it gets an ADR before it gets code.

Open questions

  • Signing webhook payloads.
  • Versioning the event contract.

What I learned

  • The event contract is the real boundary. Once that shape was stable, providers and transports became easy to add and just as easy to delete.
  • Hand delivery to a workflow tool. Hardcoding channels in the service meant secrets in the code and a release per channel. n8n already does that job well.
  • The bugs worth fixing weren't features. They were identity collisions and silent drops, the ways the system could fail by saying nothing.

Need something like this built?

I design and build small, well-tested systems that are honest about their trade-offs. If your team has a problem that looks like this one, let's talk.

The source code is private. I'm happy to walk through it on a call.