DEV Community

Nikhil Y N
Nikhil Y N

Posted on

Who decided that? Building a company graph on FalkorDB

Somebody posts "we go with Kafka for the event bus, tracked in ENG-1" in Slack. A week later the Jira ticket has two comments and a GitHub issue links back to it. A month later someone asks who made the call and why, and the answer is spread over three tools that know nothing about each other.

We have all lost an afternoon to that question. So for Graph Hacks we built Anexa, our entry for the Company Brain track.

Anexa connects Slack, GitHub and Jira, runs their operations as scheduled workflows, and writes everything into one FalkorDB graph per workspace. A linker then connects the pieces, so the Kafka decision above becomes a node with a source message, a person who made it and the ticket it affects. Claude Code can drive all of it over MCP, and during the hackathon it also became the linker itself.

Everything in this post ran against our own sandbox: a GitHub repo, a Jira Cloud project called ENG and a Slack channel called #anexa-test. No real company data.

The shape of the graph

The ontology is small on purpose: eight node labels (Person, Team, Project, Document, Ticket, Decision, Message, Topic) and eighteen edge types, all in graph/ontology.py. Connectors describe what they found as a delta, and the ingest step upserts nodes on (label, key), so a second sync updates instead of duplicating.

The key is where people get merged. A Person's key is email:<address> when the connector knows an email, otherwise something like github:user:<login> or jira:user:<id>. Same email, same node, and the linker never has to think about it. It only deals with accounts that hide their address.

Every edge the linker writes carries method, confidence and evidence. If we could keep only one design decision, it would be that one. When the graph tells you something, you can ask it why.

What FalkorDB did for us

Mostly it stayed out of the way, which is what we wanted from a database at a hackathon.

FalkorDB speaks the Redis protocol, so it is also our Celery broker. The compose file starts FalkorDB, the API, a worker, beat and the UI, and there is no separate Redis container. One fewer thing to run, back up and explain. The same is true on our Render deployment.

Each workspace gets its own graph, named company_<workspace_id>, plus one anexa_system graph for users, connections, workflows and runs. Tenant isolation is a graph name, and deleting a workspace's data is deleting a graph. We never designed a tenant column.

The API has a POST /graph/query endpoint that accepts Cypher and runs it as a read-only query, so the server itself rejects anything that writes. The query box in the UI and the graph_query tool that Claude Code sees over MCP share that one code path.

The FalkorDB browser came with the compose file too. Whenever the linker did something odd, we looked at the real graph there before touching code.

The real run

We connected all three services and ran an operator called "Company graph sync". An operator is a chain of actions, and this one has seven: GitHub repos and issues, Slack users, channels and messages, Jira projects and issues. The run took 7.0 seconds end to end.

The first attempt failed. The GitHub repos action called the organisation endpoint, /orgs/<login>/repos, got a 404, and the run stopped. Our test account is a user, not an org. With the org field left empty, the connector lists the account's own repos and the run went through. Real APIs are good at finding the assumptions you didn't know you made.

Right after the sync the graph had 48 nodes and 39 edges. The rules linker brought it to 78 nodes and 174 edges. After a few more workflow runs, a re-sync and a round of Claude Code linking, the final count was 93 nodes and 255 edges.

Operators can also write. "Escalate incident" takes a title and a body, creates a GitHub issue, creates a Jira ticket with the GitHub link templated into the description, and posts an alert in Slack:

tasks:
  gh     github.create_issue   title: {{ input.title }}
  jira   jira.create_issue     description: ... GitHub: {{ tasks.gh.output.url }}
  slack  slack.post_message    text: {{ tasks.jira.output.key }} {{ input.title }}
Enter fullscreen mode Exit fullscreen mode

It created GitHub issue #4 and Jira ENG-4 and posted the alert, in 2.3 seconds. The input was a made-up outage: p95 latency on /checkout up from 180ms to 2.4s "after the Kafka producer rollout (ENG-1)". That sentence matters later.

A second operator, "Daily open-bug digest", queries Jira for open bugs and posts a summary to Slack. It found 2 open bugs on its run. Schedules are cron entries that Celery beat runs: the digest is 30 9 * * 1-5 in Asia/Kolkata, and the sync runs hourly.

The linker: rules, then Claude, then a person

The rules half uses no AI. It runs five steps in order: same person, references, related items, decisions, and who knows what. References are plain regexes for Jira keys and GitHub URLs:

JIRA_KEY = re.compile(r"\b[A-Z][A-Z0-9]+-\d+\b")
GITHUB_URL = re.compile(r"github\.com/([\w.-]+)/([\w.-]+)/(?:pull|issues)/(\d+)")
Enter fullscreen mode Exit fullscreen mode

A Slack message that says ENG-1 gets a REFERENCES edge to the Jira node at confidence 1.0, with the matched text as evidence. A decision is any message that starts with "Decision:" or contains "we decided", "we go with" or "agreed to". Crude, cheap, and enough to find the Kafka decision.

On the sandbox the first pass wrote 7 REFERENCES edges, 1 SAME_AS, 4 RELATED_TO, 2 SOURCED_FROM, 1 DECIDED and 1 AFFECTS, plus topic and expertise edges. This query lists the cross-system references:

MATCH (a)-[r:REFERENCES]->(b)
RETURN labels(a)[0] AS from_label, a.source AS from_sys, a.name AS from,
       labels(b)[0] AS to_label, b.source AS to_sys, b.name AS to, r.method AS method
ORDER BY from_sys
Enter fullscreen mode Exit fullscreen mode

Three rows are GitHub issues #1 to #3 pointing at ENG-1 to ENG-3. The other four are Slack messages pointing at Jira or GitHub, including the Kafka decision message pointing at ENG-1.

The rules also merge people. Jira's "Nikhil YN" and the email-keyed "Nikhil YN" became one person through a SAME_AS edge at 0.8 confidence, method name. The GitHub account, whose display name is "Nikhil Y N" with an extra space, did not match. The name rule missed it.

Claude Code as the linker

We did not want the platform to need an API key just to use an LLM. Claude Code already sits on the other side of MCP, so we let it do the linking. Two tools make that work.

graph_link_prompt returns a catalog of people, messages, tickets and documents with their text, plus the rules: use only keys from the catalog, and every quote must be copied word for word from the items. graph_submit_links takes Claude Code's answer and sends it through the same validation code the built-in LLM linker uses. That code drops a proposal if a key doesn't exist, if a same-person pair isn't two Person nodes, if it links a node to itself, or if the quote isn't really in the text:

def _quoted(quote: str, *nodes: Node) -> bool:
    return any(quote.lower() in _text(node).lower() for node in nodes)
Enter fullscreen mode Exit fullscreen mode

In our run, Claude Code linked GitHub's "Nikhil Y N" to "Nikhil YN", the pair the name rule missed. It tied the Redis to Kafka proposal in Slack to ENG-1. It noticed that the 502 incident in issue #4 happened "after the Kafka producer rollout" and related it to issue #1, the producer work. And it turned an informal "we decided to use Kafka for events" into a decision that affects both ENG-1 and issue #1, something the rules had found but could not connect.

We also slipped in one bad proposal on purpose, with a quote that exists nowhere in either item:

{"a": "slack:msg:C0CA7UAKX4Z:1792345070.772569", "b": "jira:issue:ENG-1",
 "quote": "invented quote that is not in either text", "confidence": 0.9}
Enter fullscreen mode Exit fullscreen mode

Validation dropped it. Everything else was written.

One link, "need rate limiting" to GitHub issue #2, came in at 0.6 confidence. Anything the linker writes below 0.7 lands in a review queue, least sure first, and a person accepts the edge or deletes it. The queue held six edges: five from the keyword matcher and one from Claude. Two of the keyword ones linked Slack "has joined the channel" messages to each other, which is noise. The Claude edge was right but weakly evidenced, and 0.6 felt like the honest number.

The quote check does most of the work here. A model can invent a plausible link, but a sentence copied word for word has to exist in your Slack history.

Asking the graph

With the sandbox synced and linked, the question from the top of this post is one query:

MATCH (d:Decision)-[:SOURCED_FROM]->(m)
OPTIONAL MATCH (d)-[r]->(x) WHERE type(r) <> 'SOURCED_FROM'
RETURN d.name AS decision, m.name AS source, collect(DISTINCT type(r) + ' ' + x.name) AS links
Enter fullscreen mode Exit fullscreen mode

It returns "Decision: we go with Kafka for the event bus, tracked in ENG-1", sourced from that Slack message, with AFFECTS ENG-1: Migrate the event bus to Kafka.

A ticket's neighbourhood is a good check on how much the graph knows. For ENG-1 this returned 14 rows:

MATCH (t:Ticket {key: 'jira:issue:ENG-1'})-[r]-(n)
RETURN type(r) AS rel, labels(n)[0] AS label, n.source AS source, n.name AS name
Enter fullscreen mode Exit fullscreen mode

It has the assignee, the Jira project, GitHub issue #1, the Slack decision message, the Decision node and topics like kafka, event and producer.

Through MCP, Claude Code can run a sync, wait for the run to finish and call graph_query itself. An agent that reads a graph whose edges say where they came from doesn't have to answer from memory.

What we would change

The decision detector is a phrase list. It will miss "let's just use Kafka" and fire on someone quoting an old decision. The Claude half sees up to 200 nodes per prompt, which is fine for a team and not for a whole company.

Topic nodes are frequent words with a crude stemmer. Fine for "kafka", poor for "redis". The keyword overlap threshold was tuned on the fake demo company that ships with the repo, and the joined-the-channel pairs in the review queue show why we wouldn't trust it on real data yet.

The repo also ships a demo company with known-correct links and a precision and recall scorer. Our real sandbox has no gold answers, so we haven't put scores on it here.

Try it

git clone https://github.com/Colluded-Projects/anexa
cd anexa
docker compose up --build
Enter fullscreen mode Exit fullscreen mode

UI on port 8080, API on 8000, FalkorDB browser on 3000. Connect your own sandbox accounts, run the sync operator, then open Graph and hit Run linker. Point Claude Code at /mcp with a workspace API key and ask it to link the company graph.

Live deployment: https://web-vitj.onrender.com

Repo: https://github.com/Colluded-Projects/anexa

Top comments (0)