incidentd - Architecture

incidentd was built around a simple idea:
strong atomic guarantees + durable eventual consistency, independent of where inputs come from or where side-effects go.

It must be incident-free.

incidentd is a standalone service that manages an incident end-to-end: from declaration to resolution. It owns state, notifications, AI suggestions, status page updates, and post-incident analysis.

From day one, it was designed to feed context into AI agents. That means data lives close, and reads/writes are fast.

Platform

incidentd runs on Cloudflare Workers, using 3 primitives:

Working on Cloudflare has been surprisingly good: great DX, trivial deployment and low cost.

The only real downside is ISPs blocking thanks to a spectacularly bad ruling that gave power to LaLiga to control the internet. (irrelevant in this case since this is server-to-server)

The Durable Object

Each incident maps to a single Durable Object.

This is the source of truth for:

Every mutation follows the same pattern:

  1. Start transaction
  2. Update state
  3. Append event (published_at = NULL)
  4. Schedule alarm
  5. Commit

This is the outbox pattern.

The core idea behind incidentd is:

A single-threaded, transactional system that gives you atomicity and reliable async delivery, without distributed coordination.

Alarm-based delivery

The DO alarm drains unpublished events:

for each event where published_at IS NULL and attempts < 3:
    dispatch to IncidentWorkflow
    set published_at = now

If dispatch fails, it retries. After 3 attempts, the event is marked failed but acknowledged.
This gives at-least-once delivery with bounded retries.

The same alarm also drives agent scheduling. A single method decideAlarmAction() decides what to do next:

This keeps the reliability story local: one place decides what must happen next, and one durable log says what already happened.

The dispatcher

IncidentWorkflow bridges the incident Durable Object and the senders. It’s a long-lived workflow that processes events in order:

dispatch first event
while incident is not resolved or declined:
    wait for next event
    dispatch it

Each event is fanned out to all senders using Promise.allSettled.

Because delivery is at-least-once, senders should be idempotent (incident_id + event_id).

Events are committed sequentially in the DO, and dispatched in the same order.
No races, no inconsistent external state.

Why not Queues?

Queues have a classic problem: dual-writes. Update state in one system, enqueue in another. If enqueue fails, the event is lost.

It can be fixed (outbox tables, polling, distributed transactions), but complexity explodes.

Here, the outbox lives inside the Durable Object, and dispatch goes directly to a workflow.

Also:

Workflows are basically queues with better ergonomics

So I use them.

IDs and identifiers

Originally I used idFromName (e.g. Slack thread ID), but that made IDs non-reusable after resolution.

The plugin model

The system is structured as: receivers → handler → durable object → dispatcher → senders

ReceiverHandlerDispatcherExternaleventIncidentDOAlarm/OutboxSenderExternalevent

Each stage is decoupled

  1. Receivers -> validate + normalize input
  2. The handler -> pushes events to the DO
  3. DO -> commits state + events atomically and schedules dispatch
  4. The dispatcher -> delivers events
  5. Senders -> produce side-effects

Adapters isolate integrations:

src/adapters/<name>/receiver/
src/adapters/<name>/sender/

Adding a new integration means adding an adapter, not touching core logic.

Current adapters

Dashboard (src/adapters/dashboard/)

Slack (src/adapters/slack/)

Status page (src/dispatcher/status-page.ts)

Each adapter handles idempotency in its own way.

The AI agent

Each incident runs an agent that watches the timeline and generates suggestions.

It helps:

The agent runs asynchronously.
It reads from the timeline and writes back into it.

It is more inspired by Codex than by a classic tool-calling loop: each turn is a IncidentAgentTurnWorkflow run.

IncidentDOAgentTurncontextSuggestionsContext Provider AgentsInsights

Debouncing

Not every event should trigger a turn.

Execution

Each turn:

  1. Fetch snapshot (state + events)
  2. Call LLM with tools
  3. Optionally trigger context agents
  4. Persist suggestions as events
  5. Post to Slack

Suggestions are stored as internal events, so future turns have memory and avoid repetition.

Context agents

Context agents are separate Durable Objects that run background investigations.

That keeps the incident DO focused on coordination, not research.

They are decoupled to:

Base agent

Context agents extend AgentBase, which provides:

Bidirectional RPC

The incident DO and context agents communicate in both directions:

-> provider:

Provider ->:

Current providers

Future ones:

Prompt workflow

Users can directly ask the agent questions. Handled by IncidentPromptWorkflow:

  1. LLM decides what to do
  2. If needed, calls context agents
  3. Executes action or responds

A :fire: reaction shows progress.

Expected usage is command-like (“resolve incident”, “post status page update”), making it easy for untrained responders.

Reliability

Everything optimizes for one goal:

never lose an event

So far: ** 0 lost events**, with low double-digit millisecond latency.

Folder structure

- Adapters: src/adapters/*
- Handler: src/handler
- Core DO: src/core/incident.ts
- Alarm: src/core/incident/alarm.ts
- Dispatcher: src/dispatcher/workflow.ts
- Agents: src/agent/providers/

The only part I’m still unsure about is how context agents will interact as their number grows.

We’ll find out.

Comments

No comments yet.