DEV Community

bayuh85
bayuh85

Posted on

graphloom: give your coding agent a map of the repo instead of letting it grep 40 files

Repo: 👉 github.com/bayuh85/graphloom (MIT, Python 3.10+, standard library only, 32 offline tests)

The problem

Ask a coding agent "what touches the invoices table?" and watch what it does. It greps for invoices, opens the first match, reads 300 lines, greps again, opens the schema, opens a doc, opens another service. Ten tool calls later it has burned a lot of context and still might miss the function that builds the SQL string two modules away.

The information it needs is not hidden. Imports, calls, CREATE TABLE statements and the backtick names in your docs are all sitting in the repo. They just are not connected anywhere the agent can look them up.

So I wanted a map: build it once, cheaply and offline, then let the agent ask short questions against it and get back a few lines with file:line evidence instead of whole files.

What graphloom does

graphloom build walks a repo and writes three files into .graphloom/:

File What it is
graph.json Versioned graph. Every edge has a provenance tag and a file:line excerpt.
REPORT.md Hub nodes by degree, isolated modules, cross-layer links, suggested questions.
graph.html One self-contained interactive viewer. No CDN, works offline.

What it understands, all without a model:

  • Python through the stdlib ast module: modules, classes, functions, imports, calls
  • JS/TS with a small scanner: imports, exports, calls, fetch("/route") URLs
  • SQL: CREATE TABLE nodes and foreign keys, plus edges from code whose SQL strings name a table
  • Markdown: heading nodes linked to the symbols you wrote in backticks
  • TOML/YAML/JSON: config keys, linked to the code that reads them

Then you query the graph instead of the tree: query, path, explain (offline) and ask (one LLM call over a small subgraph). graphloom serve exposes the same tools over MCP for Cursor and Claude Code.

Quickstart on the bundled example

The repo ships a small wholesale checkout app (examples/harbor-shop): Python services, a SQL schema, a browser cart and two docs.

git clone https://github.com/bayuh85/graphloom && cd graphloom
python3 -m venv .venv && . .venv/bin/activate
pip install -e .
cd examples/harbor-shop
graphloom build .
Enter fullscreen mode Exit fullscreen mode
nodes: 67
edges: 108
  parsed: 75
  linked: 33
  llm: 0
wrote graph: .graphloom/graph.json
wrote report: .graphloom/REPORT.md
wrote html: .graphloom/graph.html
Enter fullscreen mode Exit fullscreen mode

Now the question from the intro. "From the HTTP route, how do we end up sending a receipt email?"

graphloom path post_invoice send_receipt
Enter fullscreen mode Exit fullscreen mode
path: post_invoice -> send_receipt
resolved: fn:backend/app.py:post_invoice -> fn:backend/notify/emailer.py:send_receipt
hops: 4
directed: yes

1. fn:backend/app.py:post_invoice
   invokes [parsed] -> fn:backend/billing/invoices.py:create_invoice
   backend/app.py:10  return create_invoice(payload, settings)

2. fn:backend/billing/invoices.py:create_invoice
   invokes [parsed] -> fn:backend/billing/payments.py:record_payment
   backend/billing/invoices.py:39  record_payment(str(payload["invoice_id"]), total, "card", str(payload["email"]))

3. fn:backend/billing/payments.py:record_payment
   invokes [parsed] -> fn:backend/notify/emailer.py:queue_receipt
   backend/billing/payments.py:12  queue_receipt(email, invoice_id)

4. fn:backend/notify/emailer.py:queue_receipt
   invokes [parsed] -> fn:backend/notify/emailer.py:send_receipt
   backend/notify/emailer.py:5  send_receipt(email, invoice_id)
Enter fullscreen mode Exit fullscreen mode

(Trimmed slightly for width.) Four hops, each with the exact line that makes the call. An agent can cite that, or open just those four lines.

And "what touches the invoices table?":

graphloom query invoices --budget 1600
Enter fullscreen mode Exit fullscreen mode
## tbl:invoices
kind: table   layer: data   name: invoices
at: sql/schema.sql:17
columns: id, customer_id, sku, total_cents, status
edges:
  -> ties [parsed] tbl:customers (customers)  sql/schema.sql:17
  -> ties [parsed] tbl:products (products)  sql/schema.sql:17
  <- mentions [linked] doc:docs/billing.md:h:creating-an-invoice  docs/billing.md:9
  <- ties [parsed] tbl:payments (payments)  sql/schema.sql:25
  <- touches [linked] fn:backend/billing/invoices.py:create_invoice  backend/billing/invoices.py:35  "INSERT INTO invoices (customer_id, sku, total_cents, status) "
Enter fullscreen mode Exit fullscreen mode

The --budget flag matters for agents: the output stops at roughly that many tokens and says truncated: yes when it does.

Provenance: what was read vs. what was guessed

Every edge carries one of three tags:

Tag Meaning
parsed Read straight from syntax: an import, a call to a known function, a foreign key
linked Resolved afterwards: a backtick in a doc, a table name inside a SQL string, a fetch("/invoices") matched to a route
llm Added by the optional semantic pass, and kept only if it anchors to a node that already exists

This is the part I care about. When an agent says "create_invoice writes to invoices", you can see that came from a linked edge on line 35, not from the model's imagination.

A real repo: httpx

The toy example is nice, so I also pointed it at encode/httpx (commit b5addb6, about 280 KB of Python plus the docs).

$ time graphloom build .
nodes: 1767
edges: 3388
  parsed: 2244
  linked: 1144
  llm: 0

real    0m0.494s
Enter fullscreen mode Exit fullscreen mode

Half a second, no network. The hubs table in REPORT.md reads like a cheat sheet of the codebase: Response (degree 160), Client (152), MockTransport (101), Request (99), AsyncClient (84).

How does Client.get reach send?

graphloom path fn:httpx/_client.py:Client.get fn:httpx/_client.py:Client.send
Enter fullscreen mode Exit fullscreen mode
hops: 2
directed: yes

1. fn:httpx/_client.py:Client.get
   invokes [parsed] -> fn:httpx/_client.py:Client.request
   httpx/_client.py:1053  return self.request(

2. fn:httpx/_client.py:Client.request
   invokes [parsed] -> fn:httpx/_client.py:Client.send
   httpx/_client.py:825  return self.send(request, auth=auth, follow_redirects=follow_redirects)
Enter fullscreen mode Exit fullscreen mode

That answer is 609 bytes. httpx/_client.py alone is 65,713 bytes, which is what an agent typically reads to find the same thing.

To be fair about the limits: graphloom does not do type inference. Asking for a path from Client.get to HTTPTransport.handle_request comes back directed: no, routed through the "Custom transports" doc heading, because the real call is self._transport.handle_request(...) and a static scan can't know what self._transport is. The output tells you that (directed: no, linked edges), so the agent knows to go read the code at that point instead of trusting the path.

Where the model comes in (and why it stays cheap)

The graph never needs a model. Two optional features do:

  • graphloom build --semantic --max-calls 4 sends doc chunks only (never source code) to a model, which proposes concepts and anchors. Results are cached by content hash, so a second build of unchanged docs costs nothing.
  • graphloom ask "question" retrieves a small subgraph and asks the model to answer citing node ids and file:line.

For ask "how does Client.get end up sending the request?" on httpx, the retrieved context sent to the model is about 10 KB, roughly 2.5k tokens. That's one small call per question, not a whole file per tool call.

Because the calls are small and frequent, the price per token matters more than the model's size. graphloom defaults to AtmoRouter, a cheap AI API gateway with an OpenAI-compatible API (and an Anthropic-compatible one), and an OpenRouter alternative where one key covers 109 models at an average of 83.7% below official prices. The default model is zai/glm-5.3-flash.

export ATMOROUTER_API_KEY=ar-your-key-here
graphloom build . --semantic --max-calls 4
graphloom ask "what touches the invoices table?"
Enter fullscreen mode Exit fullscreen mode

It's not locked in. Any OpenAI-compatible endpoint works:

Variable Default
GRAPHLOOM_BASE_URL https://atmorouter.dev/v1
GRAPHLOOM_API_KEY falls back to ATMOROUTER_API_KEY
GRAPHLOOM_MODEL zai/glm-5.3-flash
GRAPHLOOM_API_STYLE openai (or anthropic to POST {base}/messages)

Using it from Cursor or Claude Code

Build once, then register the MCP server. The server speaks newline-delimited JSON-RPC over stdio and exposes graph_query, graph_path, graph_explain and graph_ask.

{
  "mcpServers": {
    "graphloom": {
      "command": "graphloom",
      "args": ["serve", "--graph", ".graphloom/graph.json"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Put that in .cursor/mcp.json for Cursor or .mcp.json for Claude Code. Then tell the agent to try graph_path / graph_query before grepping. Three of the four tools are offline, so you only pay when it calls graph_ask.

Try it

It's MIT licensed, and the extractors are small, readable files (extract_python.py, extract_sql.py, ...), so adding Go or Rust support is a contained PR. If it saves your agent some file reads, a star on GitHub helps other people find it.

Top comments (1)

Collapse
 
skepagent profile image
SkepAgent •

provenance 那三个 tag 才是精髓。大部分 agent 上下文工具在碰到 self._transport.handle_request 这种动态分发时都选择悄悄失败,而你选择显式输出 directed: no 并路由到 doc 边,相当于把「我不知道」本身变成了可查询的证据。这点和 --budget 截断旗是配对的:没有 token 上限,hub 节点(Client degree 152 那种)会把 path 展开成上下文炸弹。

一个实际问题:build 在 httpx 上只要 0.5 秒,理论上每次 agent 会话前都可以 rebuild,但谁负责触发?git hook 还是 serve 端懒加载?