The newest deploy is almost never the cause. So I gave on-call a graph. (On-call Brain, Graph Hacks by WeMakeDevs x FalkorDB)
It's 3 am. Your phone says checkout p99 latency above 2s. You open the deploy log and look at the newest change: a search deploy from eight minutes ago. You roll it back. Nothing changes.
The real cause was a config change on the payments gateway forty minutes earlier. Checkout calls payments, payments calls the gateway, and someone cut its connection pool from 200 to 20. A list of deploys can't tell you that. A graph of how your services connect can.
That became On-call Brain, my entry to Graph Hacks by WeMakeDevs and FalkorDB.
Click to watch the 4-minute demo: the web app, Slack, Claude Code over MCP, and the graph in FalkorDB Browser.
TL;DR
- Three agents (triage, investigator, comms) work every incident over one FalkorDB graph of the company: services and their dependencies over time, owners, on-call rotations, changes, docs, tickets, chat and past incidents.
- Every answer comes with proof: the evidence path, the suspects ruled out and why, and the Cypher behind each step, with FalkorDB's own timing and query plan.
- When a human confirms a cause, it becomes memory, and the next similar incident is solved in half the steps.
- It's an MCP server first (33 tools), with a Claude Code plugin, Slack and an alert webhook, plus a web UI. Live at https://13-127-186-74.sslip.io/, with every graph on FalkorDB Cloud.
- On 200 generated incidents it finds the planted cause 186 times. A plain change log finds it zero times.
Product thinking: who is this for?
The user is the engineer on call, half awake, with one question: what do I do right now? That shaped every decision:
- Answer first, proof one click away. The screen leads with the cause, how sure it is, who is paged and the fix. "Show me why" holds the graph and the queries for when you want to check.
- Go where they already work. On-call engineers live in their editor, Slack and their alerting tool, so the core product is an MCP server. The web page is one window onto it.
- Humans close the loop. The agents propose; only a person can confirm a cause. That confirmation is what the system learns from.
- Be honest about confidence. "Very likely: 4 of 5 suspects ruled out" says why, and a cause the graph can't support is labelled "needs a human" instead of guessed.
Architecture
Every graph tool is defined once, in one registry, and served three ways: to the agents, over MCP and over HTTP. The agents never call each other. They read and write one shared incident board in the graph, so any agent can be restarted mid-incident and pick up where it left off. Claude Opus 5.5 chooses each step and writes a one-line reason; without an API key, a deterministic planner does the same job, which is also what the tests run against.
FalkorDB is the only database. Besides one graph per company, it holds a coordination graph (locks, rate limits and the LLM budget) and two libraries of functions that run inside the database. More on why below.
The graph model
The most important design choice is that edges carry time. DEPENDS_ON, OWNS and ON_CALL all have validity windows, and nothing is ever deleted. When checkout stopped calling legacy-billing three days ago, that edge got a valid_to. Every query asks what was true at the moment of the alert. Each company gets its own graph in FalkorDB, so memory and knowledge never mix between tenants.
Facts carry a second time too: recorded_at, when the graph learned them. If CI reports a change after the incident opened, the agents still use it, and the answer lists it as "learned late" instead of pretending it was known all along.
The query that does most of the work
Finding suspects is a temporal multi-hop walk: start at the alerting service, follow up to six dependency hops, keep a path only if every edge on it was live at alert time, then take the recent changes on the services reached, nearest first. In plain Cypher:
MATCH (i:Incident {id: $incident_id})-[:TRIGGERED_BY]->(a:Alert)-[:FIRED_ON]->(o:Service)
MATCH p = (o)-[:DEPENDS_ON*0..6]->(s:Service)
WHERE all(r IN relationships(p)
WHERE r.valid_from <= a.at AND (r.valid_to IS NULL OR r.valid_to > a.at))
WITH a, s, min(length(p)) AS hops
MATCH (c:Change)-[:CHANGED]->(s)
WHERE c.at >= a.at - $window AND c.at <= a.at
RETURN c.id AS change, s.id AS service, hops
ORDER BY hops, c.at DESC
That version has a cost: the WHERE filters paths after FalkorDB has expanded them, so the walk still wanders into every retired dependency's subtree. The product version flips it around. It starts from the few changes in the window (a range index on Change.at), then calls a JavaScript function that runs inside FalkorDB and walks only live edges, stopping as soon as it has reached every changed service:
WITH o0, hits, [o IN [o0] + folded |
{o: o, reach: oncall.live_reach(o, alert_at, 6, [h IN hits | h.s.id])}] AS walks
A test checks that it returns exactly the same rows as the plain form. It's as fast at 1,000 services and 2 to 4 times faster at 2,500.
Each suspect is then checked: is there a live path from the alert to its service, and did errors appear along it, in order, after the change? For the first incident:
| Suspect | Verdict | Why |
|---|---|---|
| search deploy (newest change) | ruled out | no dependency path from checkout to search |
| legacy-billing deploy | ruled out | checkout's dependency on it was retired 3 days ago |
| pricing deploy | ruled out | rolled back before the alert |
| cart deploy | ruled out | cart showed no errors after it |
| payments-gw config change | cause | errors travel payments-gw, payments, checkout |
Legacy-billing is the interesting one: it deployed and threw errors right before the alert, so any rule that watches errors blames it. Only the retired edge rules it out.
Memory you can measure
Confirming a cause writes CAUSED_BY and a runbook back into the graph. Three days later, refunds slows down. Recall is structural, not textual: it finds resolved incidents whose cause sat on the new alert's live dependency path. The checkout incident matches, because the same gateway sits upstream of refunds, and its fix is tested first. For a fair comparison the same alert also runs on a copy of the graph that never saw the first incident:
| Investigator steps | Suspects tested | |
|---|---|---|
| With memory | 3 | 1 |
| Without memory | 6 | 4 |
Three alerts, one incident
Checkout, refunds and subscriptions alert within four minutes. Triage projects the alerting services and the upstream services that went red into a scratch graph and runs WCC (weakly connected components) to decide which alerts are one incident. A postgres-orders change explains all three and leads; a subscriptions deploy explains only one, so it's kept as "also possible" rather than thrown away. Criticality for paging comes from PageRank on a point-in-time projection of the live dependencies.
The company brain
Docs, decision records, postmortems, tickets and chat sit in the same graph, linked to the services, changes and people they mention. "Who owns kafka?" returns the team, who is on call now and the sources, with an older decision record flagged as superseded. A follow-up like "and who is on call for it?" works because each conversation is stored in the graph too. No model runs here: full-text search finds the documents, and the graph decides what's current and what to cite.
Beyond the web page
- Claude Code plugin: ask "what breaks if postgres-orders goes down?" and Claude picks the blast-radius tool by itself.
-
Slack:
/oncall who owns payments-gw?answers inline, and/oncall incident subscriptions latencyruns the agents and posts the result with Confirm and Wrong cause buttons. - Webhook: an alerting tool posts the alert, the agents run headless, and the status update comes back.
Going all in on FalkorDB
Late in the hackathon I moved the live site's graphs to FalkorDB Cloud and went through the docs looking for every place the database could do the work instead of my code. What changed:
| FalkorDB feature | What it does here |
|---|---|
| FalkorDB Cloud | Every graph of the live site, in the same AWS region as the app: 1.2 ms median per query. The same code runs on a local FalkorDB; only the connection string changes. |
| A graph for coordination | Cloud allows graph commands but not Redis's SET, INCR or EVAL, so the run lock, rate limits and LLM budget are Lease and Counter nodes. A UNIQUE constraint on Lease.key is the mutex: 16 racing clients, exactly one winner. |
| User-defined functions | The blast-radius and suspect walks are JavaScript functions (oncall.live_upstream, oncall.live_reach) that skip dead edges as they walk. |
| FLEX | FalkorDB's own function library. flex.text.jaroWinkler turns a typo like kafak into "did you mean svc:kafka?", on every surface. |
| Constraints | UNIQUE ids on every label. DEPENDS_ON and OWNS must carry valid_from (MANDATORY), so the database refuses an undated dependency. Each dependency has a UNIQUE key, so 12 racing writers with no lock still create one edge. |
algo.maxFlow |
"Is there a way around this service?" counts the routes that share no dependency edge. |
GRAPH.PROFILE / EXPLAIN |
A Plan button on every query in the trace, and each step shows FalkorDB's own run time and whether the plan came from its cache. A test runs EXPLAIN on every named query and fails on any full-label scan nobody has justified. |
GRAPH.COPY + RENAME |
A company is seeded on a staging graph and swapped in whole, so nobody ever reads a half-built graph. |
The Free tier keeps nothing across a restart, so a watchdog checks once a minute and puts back the constraints, the functions and the demo company.
Running it in public
The app runs on one ARM instance on AWS, in Docker behind Caddy for HTTPS, with no SSH and secrets in SSM. The graphs live on FalkorDB Cloud in the same region. A public demo with a real model behind it needs guardrails: per-client rate limits, a cap on concurrent runs, daily and hourly token budgets (past them the rules planner takes over and says so), request size limits, security headers, and a token on every outside write. Every issue the security scans found is fixed.
Does the graph actually matter?
An eval of 200 generated incidents, each with a planted cause zero to three hops upstream, decoys of every kind, unrelated errors, silent hops and cascade victims:
| Method | Found the planted cause |
|---|---|
| On-call Brain | 186/200 |
| Change log that also watches errors | 134/200 |
| Keyword match on the alert name | 57/200 |
| Newest change in the window | 0/200 |
Design decisions and trade-offs
| Decision | Why | Trade-off |
|---|---|---|
| Time on every dependency and ownership edge | answers depend on what was true at alert time | every query carries a time filter |
| Agents share a board in the graph, not memory | restartable, inspectable, every step logged | one extra read per decision (1 to 2 ms) |
| Structural recall instead of text embeddings | incident titles all look alike; where the cause sat is what matters | needs a dependency graph to exist |
| Algorithms on scratch projections | PageRank and WCC must ignore retired edges | a copy per run (small graphs, fast) |
| Hot walks as functions inside FalkorDB | they drop dead edges while walking instead of after | JavaScript to keep in step with a plain-Cypher twin (a test compares them) |
| Coordination in a graph, not Redis keys | Cloud has no SET/INCR; one database to run |
a lock is a query and a constraint |
| Deterministic planner beside the LLM | works without a key, testable, a safe fallback | two code paths to keep in step |
| MCP first, web second | engineers live in editors and chat | more surfaces to secure |
What I learned
- Model time explicitly. Retiring an edge instead of deleting it was the single most useful decision.
-
Let the database enforce safety. Every read runs as
GRAPH.RO_QUERY, so a read tool can't write even if its query is wrong, and constraints refuse bad data whoever writes it. - Independent review catches what the builder can't. Every round of separate code, security and design review found real issues, including two-minute hangs under load.
-
FalkorDB details worth knowing:
-
shortestPathworks inWITHandRETURN, not inMATCH. - A variable-length bound can't be a parameter (
*1..$depthfails), so the hop limit is a function argument. - A MANDATORY constraint is checked the moment the edge is created, before any
SETin the same query, so the required property goes inside theMERGEpattern. - Create constraints only when they're missing, never while writes are running.
- On 4.22, two
GRAPH.UDF LOAD REPLACEcalls close together under traffic can crash the server. Load a library once, and replace it only when its code changed. - FalkorDB Cloud allows a fixed list of commands. Design for graph commands only and it just works.
-
What I'd improve next
- Tell "red because of this change" apart from "red at the same moment for another reason": most of the eval's misses are that case.
- A path for incidents with no change behind them, such as pure capacity problems.
- Real ingest from CI/CD, monitoring and chat into the write tools that already exist (
record_change,record_doc,record_message,record_ticket). - Per-user auth on the public API, and runbook confidence that decays with age.
Try it
- Demo video: https://youtu.be/eriObL8LlsQ
- Live: https://13-127-186-74.sslip.io/ (press "Simulate an incident")
- Recorded runs, no server: https://harz05.github.io/oncall-brain/
- Code, graph model and every Cypher query: https://github.com/harz05/oncall-brain
Built with Python, FastAPI and FalkorDB, with Claude Opus 5.5 planning at runtime and Claude Code as my coding assistant. Thanks to WeMakeDevs and FalkorDB for Graph Hacks.








Top comments (0)