← Blog
· OLAV Team

Your Agent Subscription Already Runs Network Analysis — It Just Cannot Reach Your Network

Two open repos put a real network model in front of the agent platform your company already pays for: olav-collector on the jump host, olav-skills on the workstation, and nothing but a directory of text between them.

netops skills collector claude-code enterprise workflow

Your company pays for an agent platform. It reasons well about network engineering in the abstract, and it knows nothing about your network — because the devices that hold that information sit behind a jump host, on a management network with no route to the internet, and the platform runs on a laptop that has no route to them.

The two usual answers both fail, in opposite directions. Standing up an MCP server in the DMZ means new inbound paths, a security review, and a service to keep alive. Pasting show output into a chat window works exactly once and produces no model of anything — the next question starts from nothing.

There is a third shape, and it is boring in the way infrastructure should be: collect raw text where the devices are, and apply structure where the agent is. Nothing but a directory of text crosses between them. As of today both halves are public:

flowchart LR
    subgraph MGMT["management network — no internet route"]
        D1[("switches<br/>routers<br/>firewalls")]
        JH["jump host<br/><b>olav-collector</b><br/>netmiko + pyyaml"]
        D1 -- SSH, read-only --> JH
    end
    B[["bundle/<br/>manifest.yaml<br/>devices/*/*.txt"]]
    JH -- "a directory of text" --> B
    subgraph WS["workstation — where your agent runs"]
        P["<b>olav-skills</b><br/>7 skills · 32 scripts"]
        DB[("DuckDB<br/>raw + parsed")]
        A["agent platform"]
        P --> DB
        A -- natural language --> P
    end
    B -- scp --> P

The jump host does not know what a parser is

This is the design decision everything else follows from, so it is worth stating plainly: the collector never parses anything. No ntc-templates, no template library, no model, nothing to keep in sync. It logs in, runs commands, saves the output verbatim, writes a manifest.

That inversion is not laziness. The jump host is the least maintainable machine in the estate — narrowest access window, tightest change control, least business holding state. Parsing knowledge belongs where the parsers live, which is the machine doing the analysis. And because it works that way, a parser written in November applies to output collected in August. Nothing has to be re-collected.

# on the jump host
pip install -r requirements.txt          # netmiko + pyyaml, that is the install
cp tasks/hosts.csv.example tasks/hosts.csv
$EDITOR tasks/hosts.csv                  # hostname,ip,platform

python collect.py --dry-run              # per-device command counts, no SSH
python collect.py

With no --task, that collects the whole command library for each device’s platform — 143 commands for cisco_ios, 23 for juniper_junos. Deliberately broad, because the scarce resource is the access window, not disk. Collect narrowly and you go back for a second visit; collect broadly and the material is already there when a question arrives that nobody asked on the day.

Two things that breadth forces you to get right, both of which the tool handles:

Commands that hang. The library was seeded from the ntc-templates index, which describes what can be parsed and says nothing about what is safe to run unattended. A bare ping on IOS opens the extended-ping dialog and the session then waits for input until the read timeout — per device, per retry, for nothing. Eight such rows exist across 69 platforms; they are excluded by class in exclusions.csv and a test fails if a new one appears.

Config bodies. show running-config carries enable secrets, SNMP communities and local users in plaintext. It is collected by default, because config-bearing analysis is most of what a bundle is worth — and a default everyone overrides is worse than one they were warned about. So the run says it once, before it writes anything:

WARNING This sweep collects config bodies — the bundle will contain credentials
        in PLAINTEXT until it is ingested. Treat it as a secret in transit and
        at rest, or re-run with --no-config.

Redaction happens at ingest, on the other machine. Until then the directory is a secret. --no-config drops the class if that is the wrong trade for you.

Your own commands go in user_commands.csv, not in the shipped library — it is gitignored, merged on top, and works even for a platform the library never seeded. No template needs to exist for it. Unparsed output is not waste; see below.

Moving it is a copy

scp -r output/2026-08-19/041302 you@workstation:~/captures/acme/

That is the whole transfer. No templates, no parsers, no agent credentials on the jump host, no inbound path to it. For a security review this is the useful property: the thing that touches devices is a ~730-line Python script with two dependencies and no network egress beyond SSH, and the thing that talks to an LLM never touches a device at all.

The workstation half is a pip install and a copy

python -m venv .venv && . .venv/bin/activate
pip install ./runtime                    # ~120 MB, and no OLAV distribution
cp -r skills/* ~/.claude/skills/

The pack carries the library code it needs under runtime/ — 45 modules, vendored as byte-identical copies, declaring only third-party dependencies. It does not install OLAV, which means a platform refactor cannot change what a pack you downloaded weeks ago does.

Then you stop typing commands and start asking:

“import the bundle in ./captures/acme/041302”

The agent picks the importer skill and runs the script with the path from your sentence. One thing to know: the project root is OLAV_HOME if set, else the nearest ancestor directory containing .olav/, else where you are. On any machine that has run OLAV once, ~/.olav exists — so every directory under ~ resolves to ~. If you want an engagement to live in its own directory, mark it first:

mkdir -p .olav                           # this directory is now the project

The import reports which database it wrote (db_path), so you confirm rather than assume.

The first import “fails” and that is the point

A real first sweep of two devices landed 166 command outputs. Seven parsed.

devices 2 | landed 166 | parsed 7 | unparsed 159
by reason {'parser_no_match': 155, 'no_parser_registered': 4}
learn queue:
  recipe  juniper_junos  show bgp summary        x1
  recipe  cisco_ios      show ip ospf neighbor   x1
  -       cisco_ios      show access-list        x1

A low parse ratio on a first sweep is expected, not a failure. Everything landed in raw_output_store whether a parser existed or not, and the unparsed set is a work queue — ordered so that commands a topology recipe declares come first, because those become queryable views the moment they parse.

Closing one is two calls with you (or the agent) in the middle:

echo '{"platform":"cisco_ios","command":"show access-list",
       "samples":[{"device":"R2","raw_output":"..."}]}' \
  | python learner/scripts/prepare_learn.py     # returns the drafting prompt

echo '{"platform":"cisco_ios","command":"show access-list",
       "parser_response":"# OLAV_DSL: textfsm\nValue ...", "samples":[...]}' \
  | python learner/scripts/finish_learn.py      # validates, then freezes

Re-run the import and the same bundle parses more. No second site visit, no re-collection — the raw text never left. A TextFSM template goes live on the next parse, fitted to however many samples you gave it, so pass every sample you have.

flowchart TD
    C["collect broadly<br/>166 outputs"] --> I["import"]
    I --> OK["7 parsed → queryable views"]
    I --> GAP["159 unparsed → raw_output_store"]
    GAP --> Q["learn queue<br/>recipe-declared first"]
    Q --> W["you write one TextFSM"]
    W --> F["finish_learn: validate + freeze"]
    F -. "re-import the SAME bundle" .-> I
    OK --> ASK["ask questions"]

This is what makes the arrangement compound rather than decay. Every engagement teaches the workstation something, and what it learns applies retroactively to everything already collected.

What you can ask once it is in

echo '{"query": "which devices are in the snapshot"}' | python analyzer/scripts/execute_sql.py
echo '{}' | python topology/scripts/query_topology.py

Again — in practice you ask in words, and these are what the agent runs. execute_sql called with query alone returns the schema for the model to write SQL against; SELECTs run on a read-only connection and anything mutating is refused rather than executed. The seven skills cover: land a snapshot (importer), query it (analyzer), topology as queryable views (topology), investigation and blast radius (reporter), diagrams and report polish (writer), parser learning (learner), and a router that says which of the others answers what (netops).

What it deliberately will not do

If those limits sound like missing features, they are the reason the security review is short.

Not Claude-specific, in one respect

The pack is shipped as a Claude Code skill directory because that is what we run. But every script reads one JSON object on stdin and prints one JSON object on stdout — there is no framework binding. Any agent runtime that can execute a shell command can drive the same scripts, and the prose in each SKILL.md is plain Markdown describing when to use them.

Both repos are BSL 1.1: non-production use is free, production use is bounded by the Additional Use Grant in the licence file, and it converts to Apache 2.0 on 2030-01-01.

github.com/james-olavai/olav-collector
github.com/james-olavai/olav-skills

Start with --dry-run on one device. The interesting number is what the first import cannot parse, because that is the list of things your network does that nobody wrote a template for.