Post

Cutting NetClaw's Token Bill: Skill Scoping, Webhooks, and One Very Educational Symlink

Cutting NetClaw's Token Bill: Skill Scoping, Webhooks, and One Very Educational Symlink

TL;DR: In the last post I wired firewall alerts to NetClaw. This post is what happens on the other side of that webhook, and how I stopped it from being expensive. NetClaw injects a one-line summary of all ~192 skills into its system prompt on every turn. I built a selector that trims that list to just the skills an alert needs, wired it into the webhook receiver so it happens automatically, blew away my skill catalog once (recovered it), and learned exactly how OpenClaw loads skills. Then real alerts started firing and taught me more than the scoping did: the agent was quietly running local qwen instead of the DeepSeek I’d configured, the real context hog is tool schemas rather than skills, and even a perfect report is useless if nobody sees it. By the end it enriches every external IP with threat intel and posts its findings to Discord on its own. Here’s the whole thing, in order, with the real commands.


This one started as a budget question and turned into a tour of how NetClaw actually thinks. If you read the previous post, you watched a pfSense alert travel through Loki’s ruler, into Alertmanager, and out a webhook to NetClaw. I hand-waved the last step: “NetClaw investigates.” This post opens that box and answers the question that decides whether running an autonomous agent on every alert is sustainable or silly.


The one-sentence version

NetClaw injects a one-line summary of all ~192 skills into the system prompt on every turn; I built a selector that trims that list to just the skills an alert needs, wired it into the webhook receiver so it happens automatically, and proved the whole chain end to end, then discovered the agent was running local qwen instead of DeepSeek and fixed that too.


First, a word about how I used to handle cost

Cost isn’t a new worry in this series. Back in Part 5, when I built the bespoke automation agent, I controlled the token bill in three deliberate ways, and it’s worth understanding them, because the new problem is a completely different shape.

  1. A cheap model. The Part 5 agent ran on Claude Haiku. Small, fast, inexpensive per token.
  2. One bounded prompt. Each threat produced a single API call: here’s the IP, here’s the intel, propose one action, respond with JSON. No back-and-forth. One turn, done.
  3. A hard action cap. MAX_ACTIONS_PER_HOUR=5, enforced with a Redis sliding window, so even if something went haywire it couldn’t run away.

That worked because the agent was a calculator. It did one narrow thing, so I could keep the LLM on a very short leash: one prompt in, one decision out.

NetClaw is not a calculator. It’s a general, multi-turn, tool-calling agent. When a firewall alert lands, it doesn’t make one API call. It runs an investigation: read the alert, look up the device, query Prometheus, check Loki, maybe run a pyATS command, reason about the results, decide what’s next. That’s four, five, six turns of conversation. And here’s the kicker that makes the old cost model useless:

Every single turn re-sends the entire system prompt. Not once per session. Every turn.

So if your system prompt is bloated, you don’t pay for it once, you pay for it 4, 5, 6 times per investigation. The Part 5 levers don’t apply: I can’t make NetClaw a one-shot call (its power is the multi-turn loop), and a “5 actions per hour” cap makes no sense for an investigator. The cost lever for a general agent isn’t how often it acts, it’s how heavy each turn is. So that’s where I went looking.


Why I started poking at this

The trigger was a simple worry: “How do I use a frontier model without the bill getting silly?”

I went in assuming the fix was to shrink the giant “soul” and skill instructions that get front-loaded into every prompt. That assumption was half wrong, which is the useful part.

So the first job was to find out what’s actually in that prompt. (Same instinct as the last post: measure before you cut.)


Step 1: Measure before you cut

I didn’t want to guess. I wrote a small harness that assembles the same system prompt NetClaw uses, registers fake versions of its tools, and runs a realistic triage loop against a real model, reporting tokens per turn and whether the model called tools correctly.

The important discovery came from reading OpenClaw’s own skill loader (skillflag), not from guessing:

1
2
3
4
5
// skillflag/dist/core/list.js — builds the skill INDEX
const summary = fields.description
    ? fields.description.replace(/[\t\n]/g, " ").trim()
    : undefined;
return { id, dir, summary, version };
1
2
3
4
5
// skillflag/dist/core/show.js — reads the full body, ON DEMAND
export async function showSkill(skillDir, _id, stdout) {
    const content = await fs.readFile(path.join(skillDir, "SKILL.md"), "utf8");
    stdout.write(content);
}

Translation into plain language:

  • The full skill instructions (1.28 MB across 192 skills) are NOT front-loaded. OpenClaw already does the smart thing. It only reads a skill’s full SKILL.md when the model decides to use it (showSkill). This is called progressive disclosure, and it was already solved.
  • What IS front-loaded is the index: one name: description line for every skill, all 192 of them, on every turn.

So my original plan (shrink the soul) was aimed at a problem that didn’t exist. The real cost was the always-on catalog. Here’s the harness confirming it:

1
System prompt: 55,224 chars (~13,806 tokens)   ← full 192-skill index

And a full triage run against deepseek-v4-pro:cloud:

1
2
3
4
TOKEN BURN (one triage run):
  Prompt (input) tokens:     54,827
  Completion (output) tokens: 1,936
  TOTAL tokens:              56,763

54,827 input tokens for a single “is this switch down?” investigation. Almost all of it was the same skill catalog, re-sent on each of the four turns. That’s the multi-turn tax I described above, made concrete.


Step 2: The fix, scoping the catalog to the task

Here’s the insight that made the whole thing work: for a single alert, the agent needs a handful of relevant skills, not all 192. An “InstanceDown on a Cisco switch” alert needs alert-triage, pyats-network, pyats-troubleshoot, and a couple of monitoring skills. It does not need twilio-outbound-call or aws-cost-ops.

OpenClaw has no built-in way to say “only show these skills.” But it discovers skills by scanning a directory:

1
2
3
4
5
// skillflag/dist/core/paths.js — the directory IS the allowlist
export async function listSkillDirs(rootDir) {
    const dirents = await fsPromises.readdir(rootDir, { withFileTypes: true });
    // ...only real directories containing a SKILL.md count as skills
}

That’s the lever. If the directory contents are the index, then I can control the index by controlling which skill folders are in the directory. So I wrote select_skills.py: given an alert, rank the catalog by relevance, keep the top matches plus a pinned safety core, and copy just those into the runtime skills directory.

Ranking uses embeddings when available (the same all-MiniLM-L6-v2 model NetClaw’s memory already uses) and falls back to plain keyword matching:

1
2
3
4
5
6
7
8
9
10
11
12
def rank_embeddings(query, skills):
    """Semantic ranker. Returns None if sentence-transformers isn't installed."""
    try:
        from sentence_transformers import SentenceTransformer, util
    except ImportError:
        return None
    model = SentenceTransformer(os.environ.get("MEMORY_EMBEDDING_MODEL", "all-MiniLM-L6-v2"))
    corpus = [f"{s['name']}. {s['description']}" for s in skills]
    q = model.encode(query, convert_to_tensor=True, normalize_embeddings=True)
    c = model.encode(corpus, convert_to_tensor=True, normalize_embeddings=True)
    sims = util.cos_sim(q, c)[0].tolist()
    return sorted(zip(sims, skills), key=lambda x: x[0], reverse=True)

Running it against a real alert:

1
2
3
python3 scripts/skill-selector/select_skills.py \
  --alert '{"alertname":"InstanceDown","device_platform":"iosxe","device_role":"switch"}' \
  --k 8
1
2
3
4
5
Catalog: 192 skills  →  Selected: 8 skills
INDEX TOKEN IMPACT (per turn, injected skill index):
  Full catalog:  ~10,649 tokens
  Selected:      ~548 tokens
  Saved:         ~10,101 tokens (95% smaller)

One detail that would have bitten me

OpenClaw skips symlinked directories:

1
.filter((entry) => entry.isDirectory() && ...)  // false for a symlink!

dirent.isDirectory() returns false for a symlink-to-a-directory. So the selector has to copy real folders, not symlink them. I noted it loudly in the code so future-me doesn’t try to be clever. (Spoiler: future-me needed the reminder anyway. See Step 4.)


Step 3: Why webhooks, and how the pipeline fits together

This is the part worth slowing down on, because “why webhooks” is really “how does NetClaw wake up at all.” (If you read the previous post, you saw this from the sending side, Alertmanager POSTing an alert. Here’s the receiving side.)

NetClaw is a quiet agent. Its SOUL.md literally says so:

Quiet by default. Don’t speak unless spoken to or triggered by an alert webhook.

It isn’t polling your network in a loop asking “everything okay?” That would be expensive and noisy. Instead, it sleeps until something pushes work to it. That push is a webhook: an HTTP POST that another system fires when an event happens. It’s the difference between calling the shop every ten minutes to ask if your order is ready (polling) versus them texting you when it is (webhook). Webhooks are event-driven, instant, and cost nothing while idle.

This is worth contrasting with Part 5 one more time, because it’s the same lesson from a different angle. The old automation agent polled. APScheduler woke it every ten minutes to go ask the threat-intel API what was new, whether or not anything had happened. NetClaw does the opposite: it sleeps for free and only spends tokens when an alert genuinely arrives. Event-driven beats polling for anything that’s idle most of the time, which describes a home network’s security posture almost all day.

Here’s the chain, and where each piece runs:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Prometheus / Loki ruler (alert rules fire)
      │
      ▼
Alertmanager                     (groups + routes the alert)
      │  POST /webhook
      ▼
NetClaw Alert Receiver           192.168.3.252:8099   ← the front door
      │  1. enrich with Nautobot (what device is this?)
      │  2. scope skills to THIS alert   ← our new step
      │  POST /hooks/alert
      ▼
OpenClaw Gateway                 127.0.0.1:18789      ← spawns the investigation
      │
      ▼
Agent (DeepSeek) + MCP tools     (pyATS, Prometheus, Loki, ...)

The Alert Receiver is a small FastAPI app. When Alertmanager POSTs an alert, it looks the device up in Nautobot (your source of truth), builds an investigation prompt, and hands it to the OpenClaw gateway, which actually runs the agent. The receiver is the natural place to insert skill scoping, because it’s the one spot that knows which alert is about to be investigated, before the agent starts.

Here’s the core of the receiver, with our step wired in:

1
2
3
4
5
6
7
8
9
10
async def process_alert(alert: Alert):
    device_info = await lookup_device(alert.labels.instance)   # ask Nautobot

    # Scope the runtime skills directory to THIS alert before investigation.
    scope_gen = await scope_skills_for_alert(alert, device_info)

    await trigger_netclaw(alert, device_info)                  # hand off to the gateway

    # After the fresh alert session has read the scoped dir, restore the full catalog.
    await restore_skills_after_trigger(scope_gen)

The scoping call just runs the selector as a subprocess and is deliberately fail-open. If anything goes wrong, the investigation proceeds with the full catalog rather than failing:

1
2
3
if proc.returncode != 0:
    log.warning(f"Skill scoping failed (exit {proc.returncode}) — proceeding with existing catalog")
    return None

It’s controlled entirely by environment variables, off by default, so turning it on is a config choice, not a code change:

# scripts/alert-receiver/.env
SKILL_SCOPING_ENABLED=true
SKILL_SELECTOR_PYTHON=/home/ubuntu/netclaw/.venv/bin/python
SKILL_SELECTOR_PINS=alert-triage,gait-session-tracking,memory-management,humanrail-escalation,domain-expert-delegation,pyats-network,pyats-troubleshoot
SKILL_SELECTOR_K=8
SKILL_SELECTOR_RANKER=keyword

Step 4: The mistake, and what it taught me about safety

I turned scoping on, started the receiver, and a real alert fired within seconds. The log looked great:

1
Skills scoped for DeviceSSLExpiry: 8/192 skills, index ~10026 tokens saved (94.1%)

Then the next alert said something alarming:

1
Skills scoped for InstanceDown: 6/8 skills   ← 6 of 8?! not 6 of 192

The catalog had shrunk from 192 to 8. My selector had eaten the canonical skill library. The cause was one line of setup I hadn’t checked:

1
2
$ ls -ld ~/.openclaw/workspace/skills
... /home/ubuntu/.openclaw/workspace/skills -> /home/ubuntu/netclaw/workspace/skills

The runtime skills directory was a symlink to the canonical git repo copy. So “copy the 8 selected skills into the runtime dir” actually meant “delete the other 184 from the source of truth.” Most came back from git, but one skill, domain-expert-delegation, was untracked, so git couldn’t restore it. I recovered it from an editor shadow-backup and restored the full 192.

Two fixes came out of this, and both are the real lesson:

1. A hard guard in the selector. It now refuses to run if the target is the catalog:

1
2
3
4
5
if apply and os.path.realpath(target) == os.path.realpath(catalog):
    raise RuntimeError(
        f"Refusing to apply: target ({target}) resolves to the same directory "
        f"as the catalog ({catalog}). This would destroy the canonical catalog."
    )

Verified it actually blocks the dangerous case:

1
2
3
ERROR: Refusing to apply: target (~/.openclaw/workspace/skills) resolves to the
same directory as the catalog (workspace/skills)...
=== catalog still intact? === 192

2. Fix the deployment. The symlink was never how it was supposed to work. The installer copies skills:

1
2
# scripts/install-bare-metal.sh
cp -r "$NETCLAW_DIR/workspace/skills/"* "$WORKSPACE/skills/"

So I made the runtime directory a real, separate copy:

1
2
3
rm ~/.openclaw/workspace/skills
mkdir -p ~/.openclaw/workspace/skills
cp -r workspace/skills/* ~/.openclaw/workspace/skills/

Now the canonical catalog (in the repo) is only ever read, and the runtime copy is what gets scoped. Target and source can never be the same directory again.

The takeaway I keep relearning: destructive operations need to verify their assumptions about the filesystem before they run, not after. A symlink you didn’t create is exactly the kind of thing that turns “copy 8 files” into “delete 184.” It’s the same principle that made me build a rollback path into the Part 5 executor, except this time the “rollback” was git plus a lucky editor backup, which is not a plan. The hard guard is the plan.


Step 5: Prove it end to end

With the deployment fixed and scoping re-enabled, I traced a real alert through the whole pipeline. The log tells the story:

1
2
3
4
5
Skills scoped for InstanceDown: 8/192 skills, index ~9992 tokens saved (93.8%)
  — ['alert-triage','gait-session-tracking','memory-management','humanrail-escalation',
     'domain-expert-delegation','pyats-network','pyats-troubleshoot','catc-troubleshoot']
Triggered NetClaw investigation for InstanceDown on core-sw1
Restored full skill catalog after triage (interactive-safe resting state)

That last line is the other half of the design. Because all sessions share one skills directory, leaving it scoped would starve your interactive chats of skills. So the receiver restores the full catalog a few seconds after triggering, once the fresh alert session has already read the scoped set:

1
2
3
4
5
6
async def restore_skills_after_trigger(scope_gen):
    await asyncio.sleep(SKILL_RESTORE_DELAY)          # let the alert session read the dir
    async with _scope_lock:
        if scope_gen != _scope_generation:            # a newer alert re-scoped? leave it alone
            return
        # ...run the selector with --restore-all

The _scope_generation counter handles overlapping alerts: if a newer alert re-scopes while an older restore is waiting, the stale restore backs off. It’s a lock plus a version number, simple, and enough for home-lab concurrency. (If that pattern feels familiar, it’s the same shape as the two-layer dedup and the asyncio write lock from Part 5b. Concurrency bugs never really leave you, they just change costumes.)

But does the agent actually re-read the directory?

This was the question that could have sunk the whole approach. If OpenClaw reads skills once at startup and caches them, then scoping the directory does nothing until you restart the gateway. I checked two ways.

Empirically. Every alert creates a brand-new session file, timestamped right after the alert:

1
2
$ head -1 9e838628-....jsonl
{"type":"session", "timestamp":"2026-07-02T17:36:38.600Z", ...}   # alert fired at 17:36:18

In the code. The skill snapshot is rebuilt per run, from a live directory read, with no cache:

1
resolveSkillsPromptForRun  →  buildWorkspaceSkillSnapshot  →  fs.readdirSync(...)

The function is literally named ...ForRun. So a fresh session reads the directory at the moment it starts, which is exactly when our scoping is in place. Confirmed: scoping works with no gateway restart.


Step 6: Make it survive reboots

A background process that dies with your SSH session isn’t infrastructure. I installed the receiver as a systemd service, but not before catching a bug in the unit file that would have silently disabled scoping:

1
2
3
# The hardening was too strict:
ProtectSystem=strict
ReadWritePaths=/home/ubuntu/netclaw/scripts/alert-receiver

ProtectSystem=strict makes the whole filesystem read-only except the paths you list. The selector writes to ~/.openclaw/workspace/skills, which wasn’t listed, so under systemd, scoping would fail every time (fail-open, so investigations still ran, but with no savings). The fix:

1
2
Environment=HOME=/home/ubuntu
ReadWritePaths=/home/ubuntu/netclaw/scripts/alert-receiver /home/ubuntu/.openclaw/workspace/skills

Then install and verify:

1
2
3
sudo cp scripts/alert-receiver/netclaw-alert-receiver.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now netclaw-alert-receiver
1
2
3
4
active
enabled
Skills scoped for InstanceDown: 8/192 skills ...   ← scoping works under the sandbox
Restored full skill catalog after triage           ← restore works too

The fail-open design is the hero of this step. A too-strict sandbox didn’t take the system down. It just quietly turned off the savings while investigations kept running. That’s the right failure mode for an optimization: when in doubt, do the correct-but-expensive thing, never the broken thing.


Step 7: The alerts started firing, and quietly did nothing

Scoping was in and surviving reboots. Then real PfSenseWANPortScan alerts started landing, and I went looking for the triage reports they should have produced. Here’s what was in the session log instead:

1
model=qwen3.6:35b | input=65535 output=1 | stop=stop | text='The'

Read those numbers. The model consumed its entire 65,536-token context window on input and produced exactly one token before giving up. Four separate port-scan investigations, all identical: window maxed, one word of garbage, no triage. Nothing crashed. Nothing logged an error. The investigations were quietly producing nothing at all.

That one log line pointed at two separate problems. I’ll take them in order.

Problem one: the agent was on the wrong model

qwen3.6:35b is a local model with a 64K context window. I thought I’d switched the default to cloud DeepSeek by editing .env weeks ago. The agent had been running local qwen the whole time.

Here’s why. NETCLAW_MODEL in .env is only read by the installer, which bakes it into OpenClaw’s real config file:

1
2
3
# scripts/install-bare-metal.sh
model_id = os.environ.get("NETCLAW_MODEL", "qwen3.5:397b-cloud")
defaults["model"] = {"primary": f"ollama/{model_id}"}

The running agent reads ~/.openclaw/openclaw.json, not .env:

1
"agents": { "defaults": { "model": { "primary": "ollama/qwen3.6:35b" } } }

So editing .env without re-running the installer changes nothing at runtime. That’s the trap, and it rhymes with the symlink mistake: in both cases the thing I thought was the source of truth (the .env file, the runtime skills dir) was actually a copy or an input to something else. The correct, permanent fix is OpenClaw’s own CLI, which writes the config file safely (it quarantines hand-edits it doesn’t like):

1
openclaw models set ollama/deepseek-v4-pro:cloud

Verified on the next fresh alert session:

1
2
$ grep model_change b819b5ff-....jsonl
{"type":"model_change","provider":"ollama","modelId":"deepseek-v4-pro:cloud"}

DeepSeek has room to spare where qwen didn’t. The same investigation that overflowed qwen’s window ran clean on DeepSeek at around 84K tokens across a full nine-step triage. And now the scoping work pays off twice over: every turn on cloud DeepSeek burns Ollama Pro quota, so trimming ~10,000 tokens of skill index per turn stopped being a latency tweak and became the thing that keeps me under the plan’s weekly cap.

Problem two: I’d been optimizing the wrong layer

Here’s the humbling part. My scoped skill index is about 600 tokens. So what filled a 65,536-token window? Not the skills. MCP tool schemas.

NetClaw has 20 MCP servers wired in, and each one loads the JSON definitions for all its tools into every session. pfsense-mcp alone exposes 327 tools. Add the rest and the tool schemas dwarf everything else in the prompt. They overflowed qwen’s window before the alert text was even read.

That reframes the whole project. Skill scoping was a real win, but it went after the second-biggest hog. The dominant cost is tool schemas, and the same trick should apply: scope the tools to the alert, loading only the MCP servers an investigation actually needs. That’s the sequel, and I’ll come back to it at the end.


Nobody saw the report

With DeepSeek in place, the investigations started producing genuinely useful triage. One run identified the scanner, confirmed pfSense had blocked every packet, called the alert a false positive, and even suggested raising the threshold. Then it printed all of that to the session log and stopped.

The report never reached me. The receiver posts an “alert received” notice on the way in, but the findings, the part actually worth reading, went nowhere. The alert-triage skill’s “post to Discord” step called a post-discord.sh script that didn’t exist.

My first instinct was to write that script. Wrong instinct, again. NetClaw already has a native Discord path through OpenClaw’s channel bridge, the same bot it uses everywhere else. The right way to post is one command:

1
openclaw message send --channel discord --target <channel_id> --message "<report>"

No script, no second webhook. I pulled the alerts channel id from the existing webhook metadata, then edited the investigation prompt to make posting the report a required final step. Findings now land in the channel through the bot NetClaw already had. This is the third time in one project that reaching for the platform’s own capability beat building glue. After skill loading and after the model config, “check whether it already does this” has earned its place as my first move.


Answering “where is this IP from?” without building anything

A port-scan triage really wants one fast answer: is this a known internet scanner, or something aimed at me? NetClaw could already do geolocation and ASN lookups, but it had no reputation data. Meanwhile I had API keys for AbuseIPDB, OTX, and IPinfo sitting unused in .env.

Rather than build an enricher (I did that the hard way back in Part 4, four separate API clients and a scoring function), I went looking for existing MCP servers. They all exist. The only real decision was how many to add, because every MCP server inflates that per-session tool-schema cost I’d just been burned by. So I kept it to two.

First, one unified server for the paid sources. mcp-threatintel covers AbuseIPDB, OTX, GreyNoise, and abuse.ch in a single MCP with ten tools, using the exact env var names I already had. I linked it the same way NetClaw’s other MCPs are linked, as a git submodule:

1
2
git submodule add https://github.com/aplaceforallmystuff/mcp-threatintel.git \
  mcp-servers/threatintel-mcp

Second, one tiny server I wrote myself for GreyNoise. The official GreyNoise MCP wants a paid Enterprise key and ships 18 tools. The GreyNoise Community API, though, is free, needs no key, and answers the exact question with a single GET:

1
2
$ curl -s "https://api.greynoise.io/v3/community/66.132.172.181"
{"ip":"66.132.172.181","noise":true,"classification":"benign","name":"Censys",...}

So I wrote a minimal one-tool MCP around just that endpoint. One tool added to the budget, no key, no cost. It’s vendored in-tree for now; to match the submodule pattern I’d push it to its own repo and convert it later, which I noted in its README.

Keys never go into the config file. Both servers are registered through the sanctioned CLI, and the launch command sources secrets from .env at startup so they never get written to disk in the config:

1
2
3
4
"threatintel-mcp": {
  "command": "bash",
  "args": ["-lc", "set -a; source /home/ubuntu/netclaw/.env; exec node .../threatintel-mcp/dist/index.js"]
}

One caveat worth flagging for anyone copying this: GreyNoise Community is rate-limited without a key. For home-lab alert volume that’s fine, but if triage traffic grows, a free GreyNoise account key raises the limit, and it lights up the unified server’s Enterprise tools at the same time.


The payoff

I re-fired the port scan one more time and watched the whole chain run on DeepSeek:

1
2
Skills scoped for PfSenseWANPortScan: 8/192 skills, index ~10022 tokens saved (94.1%)
Triggered NetClaw investigation for PfSenseWANPortScan on pfsense

The investigation called greynoise_community_lookup, threatintel_lookup_ip, abuseipdb_check, and otx_get_pulses, classified the source as a benign Censys scanner, and posted the report to the alerts channel:

1
{"ok": true, "result": {"messageId": "1522340738463957215", "channelId": "1426234100339048701"}}

It enriched, concluded, and delivered, end to end, and the canonical skill catalog stayed at 192 the whole time.

That quietly closes a loop from the last post. When I wired up detection, a real WAN scanner tripped PfSenseWANPortScan and I had to be honest that I couldn’t tell you what NetClaw did with it. I’d only confirmed the alert was delivered and accepted. This is the other end of that thread. The investigation now runs to a conclusion I can actually read: it decides the scanner is harmless background noise the firewall already blocked, and drops that verdict in Discord. Detection hands off, and the responder responds.


How it all fits, in one breath

  • NetClaw sleeps until a webhook wakes it. Event-driven, cheap when idle, the opposite of Part 5’s ten-minute polling loop.
  • Alertmanager POSTs to the receiver, which enriches the alert with Nautobot and then scopes the skill catalog down to what this alert needs.
  • The gateway spawns a fresh session that reads the (now scoped) skills directory at startup, so the model’s system prompt carries ~600 tokens of relevant skills instead of ~13,800 of everything.
  • After handing off, the receiver restores the full catalog so interactive sessions aren’t starved, coordinated by a lock and a generation counter.
  • A hard guard and a real (non-symlinked) runtime directory make the whole thing safe against the mistake that ate my catalog once.
  • Everything runs under systemd, and the agent is finally on the model I meant to use, one with enough context window to finish the job.
  • For an alert about an external IP, the investigation enriches it with reputation and geo data from two small MCP servers before it decides anything.
  • The finished triage gets delivered to the Discord alerts channel through OpenClaw’s native message bridge, no bolted-on script.

The numbers

  Full catalog Scoped Change
Skill index ~10,649 tok ~600 tok −94%
System prompt ~13,806 tok ~3,800 tok −73%
First-turn input 13,080 tok 4,536 tok −65%

Tool-calling stayed clean the whole time: zero hallucinated tools, every triage posted its report. The cheapest token is the one you never send, and it turns out a network agent almost never needs to be reminded that it can also make phone calls.

Those numbers are the skill index alone, though, and that’s the part I now know isn’t the main event. The tool schemas are bigger, and they’re still loading in full on every session. Scoping them the same way skills get scoped is the largest saving left on the table, the sequel this post keeps pointing at.


The bigger arc

Two posts ago the goal was detection: give the firewall a brain by wiring its logs into alerts. This post was about making that brain affordable to run continuously. Put them side by side and you can see how far the approach has moved from where I started in Part 5:

  Part 4/5 (bespoke agent) Now (converged stack + NetClaw)
Detection Threat-intel scoring pipeline Loki ruler rules on measured baselines
Response Purpose-built pfSense blocker General autonomous investigator
Waking up Polling every 10 minutes Event-driven webhook
Cost control Cheap model + one-shot prompt + action cap Scope the per-turn context
Lines of code I maintain Thousands A rules file, a selector, a receiver hook

The old system was a well-built machine that did one job. The new one is a handful of small, boring integrations around tools that already existed, and it does more. That’s the whole thesis of Convergence: the win isn’t building a smarter agent, it’s stopping yourself from rebuilding what you already have, and then making it cheap enough to leave running.


A few things I’d tell past-me

  • Loud failures are a luxury. A context-starved model doesn’t crash, it returns one token and a clean “stop.” Watch input and output token counts, not just exit codes.
  • Config has layers. .env feeds the installer; openclaw.json runs the agent. Edit the wrong one and nothing changes, and you lose an afternoon finding out.
  • Reach for the native capability before writing glue. Three times here I started building something the platform already did: a Discord poster, an IP enricher, a way to trim skills. Each one already existed. I just had to wire it.
  • Tool schemas are context too. Skill scoping was the right idea aimed at the wrong layer. Scoping tools per alert is the real prize, and it’s next.

Files from this work: scripts/skill-selector/select_skills.py, scripts/model-harness/triage_harness.py, scripts/alert-receiver/server.py, scripts/alert-receiver/netclaw-alert-receiver.service, mcp-servers/threatintel-mcp/ (git submodule), mcp-servers/greynoise-community-mcp/, the reworked workspace/skills/alert-triage/SKILL.md, and the write-up in docs/architecture/skill-context-scoping.md.

Stay curious, and never pay for a token you didn’t need to send.

Need a real lab environment?

I run a small KVM-based lab VPS platform designed for Containerlab and EVE-NG workloads — without cloud pricing nonsense.

Visit localedgedatacenter.com →
This post is licensed under CC BY 4.0 by the author.