Case study · dllm-networkBack to projects
Windows desktop app · Go · Wails + React · MCP server

dllm-network

Capturing

A DevTools Network tab for your local Ollama.

It watches the HTTP traffic between your tools and a local Ollama and presents it like a request inspector: models, endpoints, status codes, tokens per second, latency, and full bodies. Then it hands that same captured data to any MCP client, read-only, so an LLM can answer questions about your own inference activity. It observes by packet inspection — your apps talk to Ollama exactly as they did before.

3
MCP tools
0
MCP resources
5000
inference rolling cap
4
linter-enforced boundaries
Response
The dllm-network dashboard after a completed request: the summary strip reads 43.6 tokens per second, 31188 ms p50 and p95 latency and an eval count of 1075; the request table shows one completed POST to /api/chat served by gemma4:12b with status 200; and the detail panel is open on the Response tab, showing the captured NDJSON stream pretty-printed with a chip reading 1017 events.
Real capture of the running app. Every number in the strip was read out of the response the tool actually observed, and the 1017 events behind the body are the individual streamed chunks it reassembled.
Capture

It sees everything talking to Ollama, including software you did not write

Most local-LLM observability asks you to change something first: point at a different port, wrap the client, install a middleware. That buys visibility only for the code you control, and only after you have modified it. This one reads the traffic itself, so an editor extension, a closed-source client and your own script all show up the same way, with no cooperation from any of them.

Capture is packet inspection through WinDivert, at the network layer, below the application that made the request. The app reassembles the TCP stream, parses the HTTP exchange, and derives its metrics from the response it actually saw — token counts and tokens per second are read out of the payload.

  • Works on software you never wrote

    No library is added to your project and no import is rewritten, so there is nothing to install into a tool you do not own. An editor extension or a closed-source client is observed on exactly the same terms as your own code.

  • Your inference keeps its original speed

    Capture reads a copy off the wire rather than sitting between your tool and Ollama. There is no extra hop on the request path to add latency, and no second process whose failure could take an inference down with it.

  • Nothing to set up, nothing to undo

    Your client keeps pointing at 127.0.0.1:11434. Open the app and traffic appears; close it and your setup is exactly as you left it, because no configuration anywhere had to learn that the inspector exists.

  • Byte-for-byte fidelity

    What you inspect is what Ollama received. The request object your code built travels untouched, and the payload on screen is the one that crossed the wire — down to the streamed chunks, reassembled in order.

Headers
The Headers tab of the detail panel, listing captured request headers — host 127.0.0.1:11434, an Ollama user agent, content-length 140, accept application/x-ndjson — and the response headers below them, including content-type application/x-ndjson, a date, and transfer-encoding chunked.
Both header sets, read off the wire. Transfer-encoding chunked and the ndjson content type are the actual bytes Ollama sent, and they are also how the tool knows the response was streamed.
A foreign client
The dashboard showing an in-progress POST to /v1/chat/completions from a different client, with the detail panel open on the Payload tab displaying that client’s own system prompt.
A separate session, and the claim above made concrete: this request came from an editor extension on a different endpoint, and its full system prompt is readable here. Nothing was installed into it.
Provenance

Every number on screen was read out of a response

Inference dashboards are easy to fake. Latency can be timed from the client side, throughput extrapolated from a partial stream, token counts estimated from character length — and the result looks identical to a measurement. This one derives its figures from the response it captured, which is why you can compare two runs and trust that the difference is real.

In progress
The Overview tab of a request still in progress. Endpoint, method, prompt size and timestamp are filled in, but tokens per second, latency, prompt eval count and eval count all read as a dash.
12:23:38 — in progress. Four fields hold a dash, because the response they are read from has not arrived.
Completed
The Overview tab of the same request once completed. The same four fields now read 43.6 tokens per second, 31188 ms latency, a prompt eval count of 24 and an eval count of 1075.
12:24:09 — completed. The response landed, and the same four fields now carry the numbers taken from it.

One request, thirty-one seconds apart, and the clearest way to see the rule at work. A dashboard willing to guess would have filled those four fields immediately with a running average and a plausible rate. This one waits until it has something to read, so a number appearing on screen tells you the measurement exists.

FieldWhere the value comes from
Request latencyMeasured across the captured exchange
Token countsRead out of the response body
Tokens per secondDerived from those two, never estimated
HTTP status codeObserved on the wire
Request and response bodiesStored verbatim, up to 16 MiB
Streaming chunksReassembled in order — 1017 of them in the capture above
Loaded modelsConfirmed against the Ollama API

What that buys you

  • Two runs can be compared directly, because both figures were measured the same way.
  • A slow request is genuinely slow — not an artefact of where the timer was started.
  • Measured telemetry stays its own category, so a figure never quietly mixes a reading with an estimate.
  • Anything the app infers from indirect signals arrives labelled, with a confidence and the observations behind it.
The MCP surface

Three tools, called in order, each one cheap

An MCP server that returns request bodies in its discovery response burns the context window before the model has asked a question. So the contract is staged: the first call describes what exists, the second returns summaries light enough to page through, and only the third — once the model has picked a single stable id — is allowed to return a body, and even then only the byte range it asked for.

  1. 01resolve_inference_context

    Orientation. What models, endpoints and statuses exist at all, and over what time range.

    Inputs

    Outputs

    • models[]
    • endpoints[]
    • statuses[]
    • timeRange
    • counts.total
    • supportedFilters

    Cheap because: Aggregates only. It never includes a body, a header or any per-inference detail.

  2. 02search_inferences

    Narrowing. Page through candidates under filters combined with AND.

    Inputs

    • model
    • endpoint
    • status
    • since
    • until
    • limit ≤ 100
    • cursor

    Outputs

    • items[].id
    • at
    • model
    • endpoint
    • method
    • status
    • statusCode
    • streaming
    • promptSize
    • nextCursor

    Cheap because: Summaries carry stable fields only; heavy headers and bodies are omitted by construction.

  3. 03get_inference_context

    Detail, bounded. One known id, only the sections asked for, and a body read in slices.

    Inputs

    • id
    • sections[] ⊂ {metadata, tokens, request_headers, response_headers}
    • body{name, offset, limit}

    Outputs

    • availableSections
    • requested sections
    • bodyChunk{offset, nextOffset, hasMore, totalBytes, truncated}

    Cheap because: The client sets the byte window. A large body is read across several calls instead of arriving whole and unasked.

Stable pagination

Page order is at DESC, id DESC — the id tie-breaker matters because several inferences can share a timestamp, and without it a page boundary could drift between two identical queries. The opaque cursor stores the timestamp, the id and the active filters, and reusing a cursor under different filters is rejected rather than silently answered, because answering it would break the stability the cursor exists to provide.

Registering the sidecar

{
  "mcpServers": {
    "dllm-network": {
      "command": "C:\\path\\to\\dllm-network-mcp.exe"
    }
  }
}

The sidecar takes no flags — it resolves the database location itself. Register it by absolute path and restart the client.

The seam

Two processes, one file, exactly one writer

The GUI and the MCP sidecar are not two modules of one program. They are two separate OS processes with separate lifetimes: you start the GUI, and your MCP client starts the sidecar whenever it feels like it. An in-memory store cannot be shared across that gap, so the storage medium is chosen by the inter-process problem — not by any wish to keep a long history.

The file both processes open

%LOCALAPPDATA%\dllm-network\telemetry.db
  • The GUI

    Read / write
    dllm-network

    DSN options

    • _pragma=journal_mode(WAL)
    • _pragma=busy_timeout(5000)
    • _txlock=immediate

    Opened once per session through sqlite.Open. It is the only connection allowed to write, and writes arrive batched from a separate drain goroutine so the capture loop never waits on disk.

  • The MCP sidecar

    Read only
    dllm-network-mcp

    DSN options

    • mode=ro
    • _pragma=query_only(true)
    • _pragma=journal_mode(WAL)
    • _pragma=busy_timeout(5000)

    A standalone stdio binary with no flags, launched by your MCP client. It resolves the database path through the same shared resolver the GUI uses, so the two can never disagree about where the file is.

The sidecar physically cannot write

Anything you connect to the sidecar — Claude Desktop, Claude Code, your own client — reaches your telemetry through a connection SQLite itself refuses to accept a write statement on. query_only(true) applies as a pragma at the connection level, so the guarantee holds regardless of what the caller asks for, and it is locked in by a regression test rather than by convention. Point an LLM at your inference history knowing the worst it can do is read it.

Guarantees

Four properties the build proves on every commit

These hold because the linter stops a build that would break them, so each one is a property of the shipped binary rather than a promise about how the code is maintained. The rule that enforces it is named on every card.

  • The MCP server can only ever read

    The read side depends on a reader port and has no route into the write-side capture pipeline at all. That makes "read-only" a shape of the program rather than a convention someone has to keep remembering, which is what makes the sidecar safe to hand to an LLM.

    Enforced bymcp-not-captureinternal/mcp/**
  • Protocol churn stays in one package

    The MCP SDK exists in exactly one place, so a breaking change upstream has one package to land in. Every other package builds and tests without the SDK present at all.

    Enforced bysdk-confined-to-mcpeverywhere except internal/mcp/**
  • The domain model is testable on its own

    The inference type stays free of any storage driver, so a test can exercise the domain with nothing running behind it. How an inference is stored is entirely the sqlite package’s business.

    Enforced byinference-domain-purityinternal/telemetry/inference/**
  • A second transport costs no domain changes

    The same type is kept free of the MCP SDK, so what a protocol wants an inference to look like never reaches the model. Adding an HTTP transport beside the stdio one touches no domain code.

    Enforced byinference-domain-purityinternal/telemetry/inference/**

The document explains why each line exists and the linter decides whether it held, and the repo states the tie-break plainly: if the two disagree, the linter wins and the doc is wrong.

Build it

Run the app, then point a client at the sidecar

Two artefacts come out of one repository: the tray app that captures, and the stdio binary that serves what it captured. Build the sidecar once, register it by absolute path, and make sure the GUI has run at least once — the sidecar only ever reads the database it finds.

$ go build -o dllm-network-mcp.exe ./cmd/dllm-network-mcp
Open the repository

What it needs

  • Windows — capture is a WinDivert driver and the app is a tray app.
  • Administrator, for per-request packet capture. Without it the app degrades to API polling.
  • A local Ollama instance, by default at http://127.0.0.1:11434.
  • Go 1.26+, Bun and the Wails v2 CLI, to build from source.
  • Disble/dllm-network

    The tray app, the MCP sidecar and the capture pipeline. Apache 2.0.

  • docs/ARCHITECTURE.md

    The data flow, the WAL seam and the four enforced boundaries, in the repo’s own words.

  • docs/mcp.md

    Build, register and use the MCP server; the three-tool reference and troubleshooting.

  • docs/passive-telemetry.md

    Confirmed-versus-inferred semantics and exactly which fields passive mode cannot give you.