Introducing Celmis: self-hosted code intelligence over a symbol graph Celmis, a self-hosted code intelligence tool, builds a deterministic symbol graph of repositories using tree-sitter, enabling cross-repository queries and code review without model involvement. The tool indexes code once and offers 18 tools, including search, review, and dependency auditing, with zero false positives in a demo PR review. Celmis is self-hosted code intelligence. It reads a set of repositories once and keeps a symbol graph of them; asking questions, reviewing pull requests, auditing dependencies, routing alerts and serving an MCP endpoint are then all different ways of reading that one index, rather than five products each holding their own copy of your code. It runs on one machine under docker compose , with whichever model provider you already pay for behind it. This is the introduction — what it does, what it refuses to do, and how to run it. The quickest way to say why it exists is to show the thing a diff-only tool structurally cannot do. I asked a question that spanned two repositories, and the answer quoted both. The question was ordinary: "How does the gateway talk to the payments service? Name the function on each side." The answer found a Python publisher: php def publish self, batch id: str, entries: dict str, int - None: """Emit one settlement event on the published topic.""" self.producer.send SETTLEMENT TOPIC, json.dumps {"batch id": batch id, "entries": entries} .encode , and a TypeScript listener in a different repository: js start : void { this.bus.on SETTLEMENT TOPIC, event = { const payload = JSON.stringify { type: "settlement", ...event } ; for const s of this.sockets s.send payload ; } ; } They never call each other. They meet on a Kafka topic. Then the answer added something nobody asked for: Duplicated contract.The topic name payments.settlement.v2 and the event payload structure are hardcoded in two separate repositories — src/config.py in payments and src/contract.ts in the gateway. Changing the topic name or the payload schema in one repository without updating the other will silently break the integration. A reviewer that reads only the diff cannot say that. It never had the other repository open. That is not a model-quality problem — no amount of reasoning recovers a file that was never in the context. Read the repositories once . Build a symbol graph — deterministically, with tree-sitter, no model involved. Then everything else is a different way of reading that one index rather than a separate product with its own copy of your code: file:line citations, across repository and language boundaries.The index is the product. The rest are surfaces. On a real pull request in a demo repository, three findings, all on real lines: js 6: const first = vals 0 ; // vals is {} — undefined, not a TypeError 7: for let i = 0; i <= vals.length; i++ // off by one 16: const n = parseInt raw ; // never throws, so the catch below is dead Three inline comments with suggestion blocks, one summary comment, zero false positives on that run . I verified all three against the file in the PR branch by hand, Eighteen tools over the same index. Here is a real session, trimmed: php -- initialize <-- 200 serverInfo: { "name": "celmis", "version": "1.29.1" } -- tools/list <-- 200 18 tools list projects get api surface bootstrap client search symbols list accessible repos start integration walk find consumers get review route incident get owner get review policy get dep audit get architecture list deprecations list dep findings … -- tools/call search symbols { "project id": "083bd97a-…", "query": "SETTLEMENT TOPIC" } <-- 200 { "matches": { "repo slug": "…celmis-demo-gateway", "kind": "variable", "file": "src/contract.ts", "line": 2 }, { "repo slug": "…celmis-demo-payments", "kind": "constant", "file": "src/config.py", "line": 9 } , "count": 2 } One query. Two repositories, two languages, the same contract symbol — from a client that has never checked either of them out. find consumers is the one I use most: it answers "what breaks if I change this" across the whole set. This is the surface I nearly left out of this article, which was a mistake, because it is the one that closes the loop. Your services are already producing alerts. They land in a channel where somebody has to work out which repository the failing service actually is, who owns it, and whether the thing that broke was touched recently. That lookup is the expensive part — not the alert. So the same index answers it. An ingest endpoint takes the alert, a binding routes it, and the card arrives in chat: POST /webhook/alerts/{token} { "severity": "critical", "repo hint": "celmis-codereviewer/celmis-demo-gateway", "title": "checkout: unhandled exception in settle " } → notif delivered event=alert received repo=celmis-codereviewer/celmis-demo-gateway severity=critical Review results ride the same rails — a finished pull-request review posts its own card: Review CHANGES · PR 4 — 0 critical · 3 error · 0 warn · 0 info . Two things worth stealing from how this is wired, both of which I got wrong first: The webhook signature is checked before anything else. Wrong signature → 401 . Correct → 202 . Replay the exact same delivery → {"status": "duplicate"} rather than a second review and a second bill. Delivery IDs are cheap; duplicated model calls are not. A failed channel test must not echo the URL it tested. Google Chat webhook URLs carry key and token in the query string — the URL is a credential . httpx puts the request URL in the exception text, and an early version of the endpoint returned str exc verbatim, which meant a failed test handed the caller back the secret it was testing. If you are building anything that tests a user-supplied webhook, go and check that path in your own code right now. The dependency audit is the one part that is deterministic end to end — native auditors and OSV.dev, no LLM in the loop. It produces two things. A CycloneDX SBOM, and an evidence pack: sbom/