Find the gaps a review cannot see.
eventgraph holds an application's actors, commands, events, read-models and screens as a queryable graph instead of a diagram. Then check reports the gaps that are structurally impossible to leave open.
npm install -g eventgraph-cli- scaffold
- Reads real source and writes back the part of the model it can prove — screens from file routing, endpoints from route registrations, aggregates from table declarations.
- check
- 32 completeness rules in 5 lanes. Every one of them can be told it is wrong, in the model, with a reason.
- slice · impact · query
- Ask about one part — the flow around an event, the blast radius of a change — instead of rendering a picture nobody can read.
- MCP server
- The same model for an agent, with writes applied directly or returned as a diff to approve, whichever the project sets.
eventgraph checka real finding
What a finding looks like.
A takeaway app takes an order, charges the card, and shows the customer a tracking screen. The payment code is correct. The tracking screen is correct. And when a payment is refused, that fact reaches neither — so the customer waits for an order that is never coming.
Nothing in either file is wrong, which is why this survives review. It is only visible once both halves are in the same model: the event exists, and no edge leaves it.
✗ orders.payment-refused no read-model or policy consumes this event [event-no-consumer] → Add a projects-to edge to a read-model, a triggers edge to a policy, or set data.terminal to a reason. 1 error(s), 0 warning(s) across 21 nodes
That output is not a mock-up. It is regenerated from site/demo every time this page is built, so a rule whose wording changes cannot leave a stale claim here.
premisewhy not a board
A board is a view, not a data structure.
Event modelling is right about the shape of a flow — actor, command, event, read-model, screen — and wrong about the storage. A swimlane board holds that shape as a picture, which is why it fragments the moment a cross-cutting concern shows up: the flow you need to see spans four lanes that were laid out for a different question.
Here the graph is the storage and the board is one projection of it, rebuilt on demand around whatever you ask about. Ask for the swimlane around one event and you get exactly that, assembled from the same nodes that answer every other question.
The model is two kinds of file
A project file naming the contexts, and one model file per bounded context. Node ids are unique within a context, so a booking aggregate and a booking screen can coexist.
context: app
nodes:
customer: { type: actor }
place-order: { type: command, src: src/orders/place.ts }
order-placed: { type: event, src: src/orders/place.ts }
edges:
issues: { customer: [place-order] }
produces: { place-order: [order-placed] }Naming src is what makes a node implemented, so there is no separate status line to keep in sync — and eventgraph verify checks those pointers still resolve, so the graph cannot quietly describe code that was renamed away.
eventgraph viewthe graph itself
One file, no server.
The frame below is the unmodified output of eventgraph view for the same model the finding above came from. Click a node to dim everything outside its neighbourhood; press Escape to clear it.
Open it on its own — it is a single self-contained HTML file, which is what makes it reviewable in a pull request.
Don't render everything
A whole-graph picture stops being readable within a few dozen nodes — the same failure that kills a growing swimlane board. So ask for a part, and every view prints how much of the graph it is showing, so a narrowed picture is never mistaken for the whole one.
eventgraph view --slice order-placed the flow around one event eventgraph view --focus place-order -d 2 two hops around a node eventgraph view --type command,event one lane at a time
eventgraph check32 rules, 5 lanes
Every rule can be told it is wrong.
A rule with no way to disagree with it becomes noise you learn to skip. So each finding names a flag that silences it — terminal, immortal, headless, detail — and most of those flags demand a reason, which lands in the model where the next reader will find it rather than in someone's memory.
The list below is generated from eventgraph rules. Every one of these was hardened against a real application rather than derived on paper.
bootstrap
3An empty graph must not report success, or the plan-forward loop has nothing to pull on.
- graph-empty
- An empty graph is not a finished one. This is the entry point of the plan-forward loop: it names the first two things to write down.
- no-actor
- Nothing has a beneficiary until somebody is named.
- no-aggregate
- Without an aggregate there is no state, so commands change nothing and events belong nowhere.
structure
13Does every node have the connections its own type requires to mean anything?
- actor-idle
- An actor who issues nothing and sees nothing does not belong in the model.
- aggregate-no-events
- An aggregate with no events has no state changes, so it is data, not an aggregate.
- aggregate-no-lifecycle-end
- Most aggregates need an end state — archived, cancelled, deleted. Missing one usually means the lifecycle was never thought through.
- command-no-actor
- Every command needs an origin: an actor, a policy, a screen — or a named non-human trigger.
- command-no-effect
- A command producing no event changes nothing observable.
- event-no-consumer
- A fact nobody reacts to is dead weight. Either something projects it, a policy acts on it, or it is explicitly terminal.
- event-orphan
- An event belonging to no aggregate has no owner of its consistency.
- event-uncaused
- Facts do not appear on their own — some command must produce them.
- invariant-unenforced
- An invariant no command upholds is prose, not design.
- open-question-blocking
- Unanswered questions gating other nodes should surface before more design is piled on top of them.
- policy-incomplete
- A policy is by definition event-in, command-out. Missing either half makes it inert.
- read-model-unused
- A projection no screen shows and no policy uses is work nobody asked for.
- screen-empty
- A screen that shows nothing and does nothing is a placeholder, not a design.
ux
8Structural only — nothing about visual design, copy or layout, which are not graph problems.
- actor-cannot-issue
- A screen offers an actor a command they may not issue — the button is there, the permission is not.
- command-buried
- A command only reachable after more than 3 navigations is effectively hidden.
- command-no-feedback
- The user does something and never learns whether it worked. Every screen looks fine on its own, which is why reviews miss it. Followed transitively through policy chains, and satisfied by notifications as well as screens.
- headless-rejection-lost
- A sensor or scheduler cannot notice a refusal — it has no screen to show one on. So a command it issues that an invariant may reject needs somewhere for that rejection to go: a retry on the sender, or a decision saying the loss is accepted. Without either, the refused call is data that silently never arrives, and the first sign of it is a reader noticing the state is wrong.
- no-entry-screen
- Without an entry point there is nothing to measure reachability from.
- screen-dead-end
- A screen the user can only leave by going back. Fine for a detail view, suspicious otherwise.
- screen-unreachable
- No path of navigations leads here from any entry screen, so the user can never arrive.
- unreadable-state
- State whose store no reader may open. Not "nothing reads it yet" — nothing *can*, because visibility is refused at the store: a table without a public flag, a collection with no read rule, a private field. Both halves get built and tested and neither is wrong on its own; the writer works, the display works, and the display is empty forever. Set data.subscribable to false wherever that is known, and this finds the events written into it that were meant to be seen.
backend
6Silent unless the model declares a backend surface, so it cannot start reporting on app models.
- command-no-rejection
- A command that upholds an invariant can refuse. If refusal produces no event, the caller is told nothing and the refusal cannot be observed, alerted on, or counted.
- endpoint-anonymous
- An endpoint that names no caller has no stated authorisation. Some endpoints genuinely are public, but that has to be a decision rather than an omission — the two look identical in code and differ only in intent.
- failure-silenced
- A rejection marked terminal is a swallowed error. `terminal` says "nothing reacts, deliberately", which is a defensible call for a success and almost never one for a failure — an unobserved refusal cannot be alerted on or counted. An unconsumed failure that is *not* marked terminal is already reported by event-no-consumer, so this rule deliberately covers only what that one lets through.
- policy-not-idempotent
- Across a real message boundary delivery is at-least-once, so every policy will eventually run twice on the same event. A policy that is not safe to repeat is a double-charge or a duplicate row waiting for a retry to happen.
- policy-spans-aggregates
- One reaction writing to several aggregates cannot be atomic — each write is its own transaction. Partial completion is therefore a state the system will reach, and it needs a compensating path rather than an assumption that it will not happen.
- read-model-consistency-unstated
- A projection fed by events is either read-your-own-write or lagging, and callers are built against whichever the author assumed. Leaving it unsaid is how "I saved it but the list is empty" reaches production.
platform
2Silent unless the project declares platforms — then a node built on one but not the other reports.
- platform-drift
- A node built on one platform but missing on another. Two codebases of the same product drift apart silently, and the evidence sits between the repositories where no single-repo tool can reach it.
- platform-unknown
- An implementation is claimed for a platform the project never declared.
quickstartthe loop
Start from code you already have.
scaffold reads real source and writes back the part of the model it can prove — screens from file-based routing, endpoints from route registrations, aggregates from table declarations, each node pointing at the file it came from. It never guesses at the parts only a person knows, which is what the rest of the loop is for.
npm install -g eventgraph-cli eventgraph init --yes scaffold a project, no prompts eventgraph scaffold | eventgraph apply - eventgraph check --next 3 the most pressing gaps, in order
Then the loop is check --next → fill one gap → check → repeat, until the only findings left are the ones you have deliberately answered.
Then hand it to an agent
The same model over MCP, so an agent can query, check and write it without shelling out. Whether writes apply directly or come back as a diff to approve is the project's call, via agent.write.
npm install -g eventgraph-mcp
The server has no configuration of its own — it finds the project by walking up from the directory it is started in, the same way the CLI does. So register it per project, from the repository root, and every client below launches it with the right model already loaded.
- Claude Code
claude mcp add eventgraph -- eventgraph-mcp
That writes it to your own machine only. Add
--scope projectto write.mcp.jsonin the repository instead, so everyone working on it gets the same server.claude mcp listconfirms it connected.- Codex CLI
[mcp_servers.eventgraph] command = "eventgraph-mcp"
In
~/.codex/config.toml. Note the underscore — Codex spells the key differently from everyone else.- Cursor · Windsurf · Zed · Claude Desktop
The common
mcpServersblock —.cursor/mcp.jsonfor one project, or the client's global config for all of them.{ "mcpServers": { "eventgraph": { "command": "eventgraph-mcp" } } }- VS Code · GitHub Copilot
Same shape, different key, in
.vscode/mcp.json:{ "servers": { "eventgraph": { "type": "stdio", "command": "eventgraph-mcp" } } }- Anything else
Skip the global install and let the client fetch it: command
npx, args["-y", "eventgraph-mcp"]. It speaks stdio and needs no environment, no token and no port.
Once it is connected, eventgraph_check is the tool to loop on — it answers "what is missing", which is the question an agent can act on, rather than "is this legal".
Prefer eventgraph_slice over reading the whole graph. A large model does not fit a context window any better than it fits a screen.