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.
#One question, two repositories
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:
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:
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.pyin payments andsrc/contract.tsin 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.
#The shape of the idea
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:
- Ask questions, answered with
file:linecitations, across repository and language boundaries. - Review pull requests, with knowledge of who else calls the function in the diff — including from a repository not in the pull request.
- Audit dependencies, and produce the artefacts a buyer or an auditor asks for.
- Fix what was found, without leaving the finding. An embedded Claude Code session runs on your own subscription inside your own installation, edits the checkout, and the runner opens a pull request.
- Route what your running services are shouting about — alerts arrive, and the index already knows which repository the failing service is, and who owns it.
- Serve it over MCP, so your own editor or agent reads the same index under the same access rules.
The index is the product. The rest are surfaces.
#What the review actually catches
On a real pull request in a demo repository, three findings, all on real lines:
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,
because a review tool that is right four times out of five is a tool you stop reading.
#The MCP server
Eighteen tools over the same index. Here is a real session, trimmed:
--> 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.
#Alerts, and why they belong on the same index
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 back what it tested. Chat webhook URLs often carry credentials in the query string, so an error message that quotes the request URL is an error message that quotes a secret. Failures on that path report the status and nothing else. If you are building anything that tests a user-supplied webhook, go and check what your own error text contains.
#Fixing, on your subscription and your machine
Finding something is half a loop. The other half is an embedded Claude Code session that runs inside the installation, edits the checkout, and lets the runner push a branch and open a pull request.
There is no key to buy from us and no model bundled. You connect your own Claude
subscription the way Cursor does — claude setup-token once on your laptop, paste the
token into Settings — and it is stored encrypted. An admin can optionally share one
subscription with a workspace, and the interface says plainly, before you save, that
sharing one person's subscription across several people may breach Anthropic's consumer
terms. That is the subscription holder's decision, and the product will not make it
quietly.
The session is not running in somebody else's cloud. It runs on the machine you already control, in a workspace isolated per session. What that buys is not "cloud access" — it is that the box holding your code is yours, and you reach it from an office laptop or a phone on a train for the same reason you reach anything else you run: it is up.
On one real finding — lodash 4.17.11 with a known vulnerability — that loop took 220
seconds from Start session to an open pull request, and changed exactly one line.
axios, minimist and node-fetch sat outdated on the lines above and below, all
flagged in the same audit, all untouched: the task said manifests only, and an agent that
tidied three more on the way past would have been a worse thing to review, not a better
one.
#The surface that quietly became urgent
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/<repo>.cdx.json CycloneDX, one per repository
findings.json what was found against those components
timeline.jsonl when each fact entered the record
MANIFEST.json sha256 of every file above
That last file is the point, with one qualification that most write-ups skip. An archive
of files is not evidence — nothing in it stops the contents from having been edited
afterwards. A manifest of digests fixes the archive to itself: recompute them, and you
know these files match this manifest. It does not, on its own, make the pack unforgeable,
because the manifest holds no digest of itself and whoever edits a file rewrites its entry
on the way past. The export returns the manifest's own sha256 in an
X-Celmis-Manifest-SHA256 header; published where the pack is not, it gives the recipient
a value that did not travel with the file, and then a third party can verify the pack
without trusting the machine that produced it:
{
"algorithm": "sha256",
"files": {
"findings.json": "921412d4bf97eb32fa4b3e8ad09447dab762f19d31ffec45bddb6d7962bf08e5",
"sbom/gateway.cdx.json": "6eadeafad0043b85a51b65ddddba84ef7b43081064ed9e1b320c62838ddc3d8e",
"timeline.jsonl": "b1815a8e3607712aa9332cd1f8d2d0ec6f93cd8dce53dd92844b8edcde790b3d"
},
"generated_at": "2026-08-26T19:09:41Z"
}
Why now: from 11 September 2026, under the EU Cyber Resilience Act, manufacturers must report an actively exploited vulnerability to ENISA and their national CSIRT within 24 hours. The SBOM itself is not mandated until December 2027 — which is the trap, because on a 24-hour clock the first question is not how to word the notification. It is whether you ship the component at all, in which service, at which version.
The document is due in 2027. The visibility it describes is needed fifteen months earlier.
#The part I am most attached to
The audit reports what it could not check, as prominently as what it found:
Not fully checked (4). Treat a zero here as unknown, not as safe.
- …demo-gateway — npm via npm-audit: no lock file
(package-lock.json / pnpm-lock.yaml / yarn.lock) — cannot resolve the tree
- …demo-gateway — all via osv-scanner: recognised no manifest or lock file here
- …e2e-probe/requirements.txt — PyPI via pip-audit: dependency resolution failed
— audited 4 pinned requirements directly, without the transitive tree
An unchecked ecosystem reports zero vulnerabilities exactly like a clean one. If your tooling cannot tell you which of the two you are looking at, that gap is the finding. Steal this behaviour regardless of what you use.
#Running it
One machine. Postgres and Qdrant bundled — no external cluster to provision.
git clone https://github.com/Celmis-labs/Celmis.git celmis
cd celmis
./scripts/init-env.sh # generates .env, every secret in the format it needs
docker compose --env-file .env up -d
docker compose ps
On a clean server that took 197 seconds from git clone to six healthy services —
measured, not estimated. About 1.1 GB of RAM at peak during indexing, 565 MB at rest.
Bring your own model key: Gemini, Anthropic, OpenAI, OpenRouter, Groq or Mistral. A free Gemini key is enough to evaluate it. No telemetry, no licence check — the only outbound calls are the ones you configure. AGPLv3, the whole thing, not open core.
#The number I am not hiding
17th of 50 on the Martian Code Review Bench offline set, stable under all three judges.
It measures one of the surfaces above — pull-request review on isolated single-repository PRs. That set has no sibling service for a symbol to have consumers in, so the cross-repository work this is built around contributes nothing to the score. It is on the front page of the site with that explanation next to it rather than instead of it.
I also audited every one of the 79 findings the benchmark counted against us. 33 were real defects the reference set was silent about. That audit is published in full, with the code for each one — partly because it is the honest thing to do, and partly because "our precision is actually higher" is a claim nobody should accept without the evidence attached.
#Known rough edges
- Semantic search needs a generated vault. Until you make one, the UI says so in an orange banner rather than quietly returning worse answers.
- The execution sandbox has deliberate internet egress, because
pip installandnpm cineed it. - No long-term support branch. Fixes land on the latest release.
- It does not make anyone compliant with anything, and it says so in its own output.
The source, the docs and the full benchmark audit are in the repository. Questions, including about the benchmark methodology — which is the part that deserves an argument — are welcome at kostiantynmakoid@gmail.com.