Project Details
Muster — Security Dispatch Agent 2026

Project Overview
Muster watches a secured site and decides who should respond when something happens. Sensors report activity — door contacts, motion zones, camera detections — and an agent judges whether it matters, picks the right responder, sends them, and confirms they took the job. A perimeter breach fires on the ops console; seconds later the nearest guard's phone is ringing with the assignment and the reasoning behind it is in the decision log.
The interesting problem is not detection, it is choosing. A patrol robot is fast and can observe with thermal and night vision but cannot intervene or open a door. A guard is slower, can act, and may be off-shift or already assigned. Some events want the robot sent ahead as a scout to resolve ambiguity before a person is committed. That judgment is what the system exists to make, and to make legible.
So the design principle is a split: hard constraints in SQL, soft judgment in the model. Capability and availability are filtered deterministically in the query, where the model cannot argue with them — an LLM must never be able to conclude that a robot can unlock a door. Claude then ranks the shortlist on distance, severity, position staleness and what else is open.
Making the Wrong Answer Unrepresentable
Telling a model not to pick an ineligible responder is a request. The eligible responder ids are instead compiled into the tool schema as an enum on each call, alongside a strict schema, so an ineligible pick is not a rejected output — it is an unrepresentable one. The action list is built the same way: "dispatch_guard" is only offered when a guard actually passed the filter.
Two layers sit behind that for what a schema cannot express. Cross-field rules — a scout-only robot named as primary, an ignore that names a responder — get one correction round fed back as an error tool result. Then a normalisation pass demotes a scout-only robot to scout regardless of what the model said. The constraint layer has the last word, not the first.
Failure resolves to escalate, never to silence: an unreachable API, a refusal, or two invalid attempts all produce an escalation with the reason written into the rationale. A broken agent should surface as "needs a human". Running without an API key does the same thing — the pipeline still runs end to end, only the judgment layer is absent.
The Decision Is the Product
An agent whose reasoning cannot be inspected is an if-statement with extra latency. Every decision is stored with its rationale and a state_snapshot of exactly what the agent saw — the event, the sensor, every responder with distance and position age, the eligibility sets, and the open incidents. The agent reads that state back out of the database rather than receiving it from the request handler, which is what makes the snapshot an honest record rather than a plausible reconstruction.
Deciding to ignore is still a decision and gets a row, with incident_id nullable on purpose. Logging only dispatches would make false negatives unmeasurable, and in security the missed event is the expensive failure. That also makes the eval harness cheap to build later: replaying a stored snapshot through a changed prompt is a pure function.
Decision, incident and assignment are written in one transaction, with pg_notify issued inside it — delivered on commit, so a listener can never see a notification for a row that rolled back.
Architecture & Engineering
- Fan-out via Postgres LISTEN/NOTIFY: an SSE connection is a live socket held in memory, and a database row cannot reach it on its own. In-process pub/sub would work until a second instance exists behind a load balancer; LISTEN/NOTIFY survives that and adds no infrastructure. The payload is an id rather than the row — 8000-byte cap, and the listener should refetch anyway so no client acts on a stale snapshot.
- SSE over WebSocket, with the gap named: the traffic is one-way and EventSource handles reconnection, so bidirectional lifecycle management bought nothing — guard acks and check-ins are fine as plain POSTs. Web Push is the correct production answer because it is the only one that reaches a locked phone, and it is recorded as a real gap rather than a design choice.
- Notifications are not durable, and that is survivable: if nothing is listening when one fires, it is gone. Postgres is the source of truth, so on reconnect the client refetches its open assignments. That is what happens when a guard loses signal in a stairwell.
- The simulator is a separate service, not a module: it posts events over HTTP exactly as real hardware would, so the backend has no idea its input is fake. The cost is a process to run; the payoff is that swapping in real sensors is configuration rather than a refactor.
- Immutable facts never join out to mutable state: EVENT carries its own position instead of reading it from the sensor, so repositioning a sensor later cannot silently relocate every historical event. Events carry two clocks — occurred_at in simulator time, recorded_at in wall clock — because the simulator runs faster than real time and one column would be ambiguous.
- Assignments are their own table because of the scout pattern: the robot goes first to resolve ambiguity and a guard follows based on what it finds. That is one incident with two assignments and a role column, which a direct event-to-responder link cannot express.
- The deployment target is a constraint, not a default: open SSE connections and a dedicated LISTEN connection rule out a serverless runtime and scale-to-zero, so Fly.io pins min_machines_running = 1. A transaction-mode PgBouncer in front of this would silently stop delivery — documented rather than discovered.
What Was Cut, and Why
The spec is written as much around what is out of scope as in it, and each exclusion carries the condition that would bring it back.
- Voice reports and incident write-ups: the highest-value next feature, deliberately deferred. The dispatch decision is the hard part; report capture is a known pattern.
- Map view: a list conveys the same decision data. A map is presentation cost with no new engineering.
- Replay and eval harness: designed for but not built — the snapshot that makes it possible is already recorded on every decision.
- PostGIS: rejected for plain x/y floats and Euclidean distance. The site is a coordinate plane, not a place; real geodesy adds a dependency without changing an answer.
- Auth, multi-tenancy, native mobile: one hardcoded site, and a PWA. The real losses are push and background location, noted as a tradeoff rather than an oversight.
The spec also ends with the four questions a client would have to answer — chief among them the relative cost of a false dispatch versus a missed real event, which sets the agent's entire decision threshold and is currently a guess.
Project Information
Solo Developer
Agentic Dispatch System / Real-Time Operations Tooling
Next.js App Router, TypeScript (strict), Postgres via Drizzle ORM
Claude Opus 5 via the Anthropic SDK, strict tool schema with eligibility baked into the enums
Server-Sent Events fanned out from a dedicated Postgres LISTEN/NOTIFY connection
Fly.io as a long-running Node process, Docker Compose, pnpm
Ops console, guard PWA, and an out-of-process site simulator posting events like real hardware
Six tables across three tiers — configuration, immutable facts, live state