Notes on migrating 35kb prompts away from Anthropic/OpenAI to Self-Hosted Ollama+opencode

Motivations:

Maybe you’re a Claude code/codex user diligently avoiding uploading personal data to LLM providers. Is it possible that the most valuable information isn’t your data- but the metadata about your sessions? The intuitions you apply in coming up with ways to coax the ai into solving problems might actually be special. It’s statistically improbable, but Claude might not be gaslighting you. It may be that you’ve actually got a real insight! Your agent sessions are transcripts of the hardest problems you work on. What would it cost you if someone had copies of them?

Last week there was public drama that shines a light on the risk that inference providers are training on user activity with the intent of delivering new discoveries. The mathematicians affected have published concerns about the ethics of frontier providers. If you missed it: https://www.theverge.com/ai-artificial-intelligence/991710/openai-navier-stokes-solution

When ‘EDR’ becomes Ethical Deflection and Refusal: 

It’s become evident that the frontier providers are not only untrustworthy- but actively devious.  If you want to protect your ideas, you cannot run inference on someone else’s hardware. It appears to be the case that everything you do with a frontier provider will get stolen. When a frontier provider talked through the navier-stokes equation situation with their lawyers, the best defensive strategy they came up with is “Cannot rule it out.”  We can’t audit their retention or their training pipeline. Apparently neither can they.

These people should not be considered partners. They are pirates.  If privacy matters, the only solution that enables verifiable protections is to operate your own hardware.

Hall Monitor as a Service

I am bewildered by OpenAI and Anthropic’s grandstanding on cybersecurity. They marvel at what they have wrought: AI beat their non-existent security controls. We are all in great ‘danger.’ Meanwhile, their llm “researchers” are running unsandboxed fleets of agents that appear to “spontaneously collaborate.” Somebody fetch me my fainting couch. 

Frontier providers can afford advising from experienced security people.  They almost certainly are paying some of them for perspective and leaving real cybersecurity guidance out of their public statements. All of this pearl clutching must be meant to solve a different problem than security.

The strongest & most accurate claim defenders can make about security is that we’ve found ways to make it “Pretty Hard” for attackers.  This seems to result only when firms pay top dollar for the best talent in both exploit mitigation and exploit development.  There’s rowdy but friendly competition between the defense and exploitation teams, and eventually you get controls that make successful attacks so expensive that they’re not worth doing. This is for the top tier companies in the country- although Microsoft seems like they’ve forgotten some lessons.

The lion’s share of pentesting done for most enterprises is performed by security generalists.  A very small subset are deep domain subject matter experts.  Usually you get “good enough” security from that support. This isn’t the glamorous or mythical practice of cybersecurity you see in movies or tv. It’s looking for the known classes of predictable mistakes. The firms that make the big/smart investments with dedicated teams of experts discover and correct many new classes of mistakes before hackers do.

Everyone who begins learning exploitation hits a phase of exploitability grief about 3 month into dedicated, practiced study. They hack something they didn’t think they had the skill to break into and it terrifies them.  They’re smart enough to know that, relatively speaking, they are an idiot, and if an idiot can do this then nothing is safe. That feeling is correct. It is also not a research finding. Some call this “imposter syndrome.” I disagree- that feeling is your first experience developing competence. Competence is knowing enough about a technical domain that you can distinguish what you know well from what you need to learn more about.

The Refusal Industrial Complex

To the LLM researchers learning and publishing about cybersecurity for the first time:  

I’ve seen you admitting you’re not security experts.  Please-when you’re hyperventilating about the cybersecurity existential threats, distinguish “exploitable” from “emergency.”  Vulnerabilities are legion.  Before Agents, vuln researchers needed insight to know where to look to find vulnerabilities.  Vuln researchers needed perseverance and esoteric knowledge to exploit them.  An agent did what you weren’t able to do. Thousands of researchers have been doing this work over the last 40+ years.  Part of being knowledgable about cybersecurity is aware of the existence of shocking amounts of unexploited vulnerabilities.  This is why frontier provider cybersecurity safety filters are so infuriating. You’re so worked up about the possibility of exploitation that you’ve implemented “ethical constraints” that prevent people from figuring out how to fix their systems.

Your safety filters prevent defenders from discovering vulnerabilities because doing so is “hacking” related. This damages defense, privacy and security for everyone. 

We need models that aren’t averse to the C-word. It’s going to take a little time, but builders will eventually learn to secure their code with helpful exploitability-detection agents. They’ll invoke against their projects during software development and as part of CI/CD pipelines. That’s only possible with models that don’t safety refuse security testing.

BYOW: Bring Your Own Weights

Defenders need llms that discover security defects. They are intolerant of false positives- which means you need to prove exploitability of a vulnerability.  Defending against hackers isn’t possible if you’re vague about what’s broken and what needs fixing. Frontier Providers need to loosen up, or people need to get serious about migrating to sovereign, self-hosted AI.

I can’t force the former- but I can help with the latter.

I’m sharing my notes about my initial pass of experiments in transitioning stronger frontier prompts off of OpenAI/Anthropic and onto my local models.  I’m trying to determine if I can rely on abliterated open weight 27b parameter models. My goal is to avoid cybersecurity refusals and protect my sessions from being snooped by arrogant frontier inference providers.

Notes on converting 35kb preprompts for use on ollama

Below are some observations about my experiences when I tried moving my most context-expensive agents to a self-hosted model:

Prompts that ran clean on a frontier API fell apart on my local hosted LLM. I have a  128 gig AMD Ryzen AI MAX+ 395. I have 32 gig allocated to the host OS, everything else is allocated to inference. 

When you try to use the larger preprompts that work well on frontier providers, ollama starts to run out of fuel within 3 minutes. The agent thrashes on repeated tool calls, re-read files it had already read, rewrote finished work. The local model’s smaller size didn’t produce the problem.  Self hosted systems have smaller context windows. The prompt, plus session history quickly exceeds the maximum context window for my self hosted system (65k tokens).  Large prompts founder and thrash.  On my system, a 35kb prompt immediately consumes 14% of total context window.  It immediately jumps into second guessing the prompts with unnecessary tool calls and double reads of files.  Context gets saturated within a few circles- and sometimes even before I get a response.  With limited context window, the pre-prompt is basically briefing a man who is reincarnated every ninety seconds.  It performs your last instructions without any awareness of the 15 preceding demands.  Whoops! 

SOP: Single Objective Prompting

But it’s not a dead end.  You can tune your prompts to work within these constraints.  Here are some things to think about if you’re going to start exploring moving Frontier Provider agents onto self hosted open weight systems.

  • You’ll need to explore splitting preprompts into single problem/resolution units, one objective each
  • creating agents in opencode is more declarative. You’ll need to store them in ~/.config/opencode/agents.  If you were getting away with using Claude code to read files as a preprompt, you’re going to need to get more formal about defining your agents.  This won’t be new for people building with anthropic SDKs.  Some of you with shell scripts and direct invocations of Claude code may have been getting a lot of miles out scrappy agent constructions- opencode’s system prompt will need to be overcome through declarative agents.
  • You’ll need to familiarize yourself with opencode’s permissions.
  • You’ll need to tune context length explicitly in ollama. The context defaults in ollama are extremely small.
  • Your agents will need to log session state to disk to facilitate more frequent session handoffs. build agents that re-read only the slice they need
  • Work to reduce the number of tool calls per agentic step
  • Replace “don’t do X” with the positive directives: e.g. “only do Y”

MTTF: Mean Tokens To Forget

Here are some Failure Signals that indicate context exhaustion. Measure over time & Monitor for them in your logs:

  • Identical tool calls back to back
  • Multiple file reads on the same file
  • Agents restating their objectives
  • tool-call parse failures (Parsing tool call responses shoves so much raw data into context that it destroys sessions like a burst pipe at your dinner party).
  • High turn counts relative to file changes

TCO: Total Custody of Output

One of the biggest assets we get from Frontier Providers isn’t the model- it’s large context windows.  They have the hardware necessary to support your inference.  As a result, they get access to the session data.

You might not know that you’ve become dependent on large context windows. You may have thought the model got better, but in some part it’s that large context windows give the model more room for Chain of Thought. Chain of Thought enables the model to emulate reasoning and infer what your poorly constructed prompt is intended to produce.  Larger context windows give agents lots of room to explore better alternative approaches to delivering your work. But it’s a Faustian bargain: you become dependent on frontier providers.  Your inefficient prompts are by CoT you can’t read directly (Anthropic & OpenAI only provide summaries of CoT to the user) and it only works with large context windows. You don’t even know that there are problems in your prompts when this is happening. With fat context and CoT, even bad prompts produce good results. Thank you OpenAI & Anthropic.  That’s been valuable. 

But they ruin it! The frontier providers are so unrelentingly greedy that they appear to be stealing the personal insights of their users.  I’ve had suspicions about my session histories for over a year. The frontier providers seem to be like Smaug, lounging on a mountain of gold. You think they’re over there, doing their thing- and you’re safe- but they lose their minds when they see a coin in your hand. They lash out and take it because gold is beautiful and it’s the dragon’s incentive. They keep warning us that they’re dangerous. What threshold is left to be crossed before you start putting your efforts into becoming self hosted?

Inspecting Claude Code’s Network Traffic with Linux Namespaces and MITM Proxying (Part 5)

Classifying all of Claude’s network connections

[part 1] Overview

[part 2] Isolating Claude’s traffic with network namespaces

[part 3] Extracting hostnames from encrypted traffic

[part 4] Decrypting HTTPS with a MITM proxy

[part 5] Classifying all of Claude’s network connections & Analyzing traffic by category

So far we’ve examined individual connections. We looked at a single WebFetch and a single decrypted API call. To build a complete picture of Claude’s network behavior, we need to exercise all of its network tools in a single session and categorize every connection it makes. This produces a full traffic matrix: which destinations Claude contacts, what each connection carries, and whether each request originates from your machine or routes through Anthropic. That matrix is the basis for the security analysis that follows.

Setting up the full test

Run a prompt that exercises multiple network tools:

./mitm-capture.sh "First, use WebFetch to fetch https://example.com and tell me the page title. Then use WebSearch to search for 'linux network namespaces tutorial' and summarize the top result."

Analyzing by category

WebFetch traffic:

We can pull specific get requests by parsing the mitm.txt file generated by the mitm-capture.sh script

grep -A 5 '>>> GET https://example.com' mitm.txt

The command above will provide you with the results of a direct GET request to the target URL from the local machine. The request includes the Claude-User User-Agent. The response body contains the full page content.

WebSearch traffic:

grep -A 5 '>>> .*search' mitm.txt   # Look for search-related requests

Observe whether search queries go directly to a search engine or route through the Anthropic API. Run this yourself. The behavior may depend on your Claude Code version and configuration. Document what you find. The traffic capture tools give you the means to answer this question empirically rather than guessing.

API traffic:

grep '>>> POST.*api.anthropic.com' mitm.txt

These are the model inference calls. Your prompt goes to Anthropic, the model reasons about tool use and the response comes back with tool invocations and final answers.

Telemetry traffic:

grep '>>> .*datadoghq' mitm.txt

Usage logging and metrics sent to DataDog’s intake endpoint.

MCP traffic:

grep '>>> .*mcp\|>>> .*cloudflare' mitm.txt

If you have MCP servers configured, claude contacts them at startup.

The traffic matrix

Traffic TypeDestinationOriginEncryptedContains
WebFetchTarget URL directlyYour machineYes (TLS)Full HTTP GET with Claude-User UA
WebSearchObserve and documentObserveYes (TLS)Search queries and results
Model APIapi.anthropic.comYour machineYes (TLS)Prompts, tool calls, model responses, API key
Telemetry*.datadoghq.comYour machineYes (TLS)Usage metrics
MCPConfigured serversYour machineVariesMCP protocol messages

The key architectural finding is WebFetch is a local operation. When claude uses the WebFetch tool, the Claude CLI running on your machine makes a direct HTTP(S) connection to the target URL. The request originates from your IP address, goes directly to the target server, and the response comes back to your machine. The Anthropic API is not in the middle.

These are the Immediate implications:

  • Claude can fetch from any URL your machine can reach
  • Internal/LAN services (http://192.168.x.x/...http://localhost:...) are accessible
  • The target server logs YOUR IP address as the requester
  • Network restrictions on your machine (firewall rules, VPN routing) apply to these fetches

Security implications of local fetch for your network

LAN accessibility by Anthropic

If WebFetch connects directly from your machine, then claude can be prompted to reach internal services:

"Use WebFetch to fetch http://192.168.1.1/ and tell me what you see."

By default, Claude Code shows each tool invocation to the user for approval before executing it. If a user approves that WebFetch call and the target is reachable (say, a router admin page), claude will fetch it. The same applies to:

  • Internal wikis and documentation servers
  • Development servers running on localhost
  • Cloud metadata endpoints (http://169.254.169.254/... on cloud VMs)
  • Internal APIs and admin interfaces

This isn’t a vulnerability in Claude Code. This is the expected behavior of a local fetch tool. The per-call approval prompt is a first layer of defense: you see the target URL before the request fires… but approval prompts are easy to click through, especially during long sessions. If you configure auto-approve policies, you’ll bypass them entirely. Network-level controls provide defense-in-depth beyond the approval model.

Internet-accessible vs. LAN-only targets

Understanding this distinction is critical:

Internet-accessible site (e.g., https://example.com):

Your machine ──── Internet ──── example.com
     │
     └── claude's WebFetch goes this way
         Target sees YOUR public IP

LAN-only service (e.g., http://192.168.1.100:8080):

Your machine ──── LAN ──── internal-server:8080
     │
     └── claude's WebFetch goes this way too
         No internet traversal needed
         Internal server sees your LAN IP

This distinction matters because Anthropic’s infrastructure can’t reach your LAN. If WebFetch were server-side, internal services would be safe by network topology. But because WebFetch is local, it can access anything your host is approved to access.

Using namespaces for defense, not just observation

You can use claude-sandbox.sh

Earlier we used network namespaces to observe Claude’s traffic. The same technique can also block Claude from reaching internal services while still allowing it to access the internet APIs it needs to function. This can turn the observation tool into a security control.

The same namespace technique from the isolation section can restrict claude’s network access:

# After creating the namespace, add restrictive iptables rules INSIDE it:
sudo ip netns exec claudesbx iptables -A OUTPUT -d 10.0.0.0/8 -j DROP      # Block RFC1918
sudo ip netns exec claudesbx iptables -A OUTPUT -d 172.16.0.0/12 -j DROP   # Block RFC1918
sudo ip netns exec claudesbx iptables -A OUTPUT -d 192.168.0.0/16 -j DROP  # Block RFC1918
sudo ip netns exec claudesbx iptables -A OUTPUT -d 169.254.0.0/16 -j DROP  # Block link-local

Now claude has internet access (via NAT) but cannot reach any internal/LAN address. You get both isolation for observation and restriction for defense.

Defense-in-depth considerations

For production use of AI agents with network tools:

  1. Network namespace isolation: Run the agent in a namespace with controlled egress. Capture and audit all traffic.
  2. Egress filtering: Allowlist specific destinations rather than blocklisting RFC1918 ranges. If the agent only needs to reach specific APIs, restrict it to those.
  3. DNS-based control: Use a controlled DNS resolver in the namespace that only resolves permitted domains.
  4. Proxy-based inspection: Route all agent traffic through a forward proxy with URL allowlisting. This gives you both filtering and a complete audit log.
  5. Credential scoping: The API key used by the agent should have minimal permissions. If your Anthropic key is stolen (e.g., from a decrypted capture), the blast radius should be limited.

That covers it. You now have sufficient skill to be able to wrap a binary in a private namespace, implement packet inspection & MITM teardown of TLS Sessions for claude so you can observe it’s requests out to claude API endpoints and any assets on the internet for scraping. We covered a ton of ground. Circle back to part 1 or head over to the github repository for this project.

Inspecting Claude Code’s Network Traffic with Linux Namespaces and MITM Proxying (Part 4)

[part 1] Overview

[part 2] Isolating Claude’s traffic with network namespaces

[part 3] Extracting hostnames from encrypted traffic

[part 4] Decrypting HTTPS with a MITM proxy

[part 5] Classifying all of Claude’s network connections & Analyzing traffic by category

In part 3, we explored pulling hostnames from an encrypted session. In this post, we’ll crack some TLS.

Decrypting HTTPS with a MITM proxy

The hostname extraction helped us discover where scraping is launching from. WebFetch connects directly to target websites from your machine. But hostnames alone don’t reveal what Claude sends in those requests: the HTTP headers, the User-Agent string, the exact URL paths, or the response bodies it receives. To see the full content of Claude’s network traffic and understand exactly what target servers receive and what data flows back, we need to decrypt the TLS.

How MITM proxying works

We use a Man-in-the-Middle (MITM) proxy to read the plaintext content of claude’s HTTPS requests and responses. The proxy sits between claude and the target server, terminating TLS on both sides so it can read the traffic in between:

claude ──TLS──► mitmproxy ──TLS──► example.com
         ▲           │
         │      reads plaintext
         │      request & response
         │
  trusts proxy's CA
  (NODE_EXTRA_CA_CERTS)
  1. Claude connects to mitmproxy (thinking it’s the real server)
  2. mitmproxy presents a certificate it generated on-the-fly, signed by its own CA
  3. Claude accepts this certificate because we told Node.js to trust that CA
  4. mitmproxy reads the plaintext request
  5. mitmproxy connects to the real server and forwards the request
  6. The real server responds; mitmproxy reads the response
  7. mitmproxy forwards the response back to claude

mitmproxy now has a complete plaintext transcript of every HTTP request and response.

Example analogy:

A MITM proxy is like a bilingual translator at a diplomatic meeting. Both sides think they’re talking directly to each other, but the translator sits in the middle, receiving every message, reading it, and forwarding it. The key requirement: each side must trust the translator’s credentials. If either side refuses to accept the translator, the communication fails.

Where this analogy breaks down: A real translator can only work if both parties agree to use one. In MITM proxying, the server doesn’t agree to anything. It sees a normal TLS connection from the proxy’s IP address. Only the client must be tricked (or, in our case, explicitly configured) into accepting the proxy’s certificates, so you only need to modify the client’s trust configuration, not the server’s.

Mental Model Check: “MITM is an attack technique.”

MITM interception on a network you don’t control is an attack. MITM proxying on your own machine, for a process you launched, with a CA you generated is a debugging technique. The mechanism is identical for both attacks and debugging. The only difference is authorization context. Burp Suite (penetration testing), Charles Proxy (mobile app debugging), Fiddler (Windows HTTP debugging), and corporate TLS inspection appliances all use the same approach. The claude-mitm.sh script is doing exactly what these tools do, scoped to one process.

Scoping the trust to one process

We scope the trust to one process. We don’t install the mitmproxy CA system-wide. Instead, we set three environment variables only for the claude process:

export HTTPS_PROXY=”http://127.0.0.1:8080″ # Route HTTPS through the proxy
export HTTP_PROXY=”http://127.0.0.1:8080″ # Route HTTP through the proxy
export NODE_EXTRA_CA_CERTS=”$HOME/.mitmproxy/mitmproxy-ca-cert.pem” # Trust the proxy’s CA

Node.js honors both HTTPS_PROXY (route traffic through a proxy) and NODE_EXTRA_CA_CERTS (trust additional CA certificates). These are set only in the shell environment of the claude process. No other process on the system is affected.

We can scope the trust this way because Node.js reads CA certificates from environment variables at startup. That means we can modify the trust configuration of one Node.js process without affecting any other process on the system. If claude were written in Go or Rust, we’d need a different approach. Those runtimes use the system certificate store and don’t honor NODE_EXTRA_CA_CERTS. The environment-variable approach works here specifically because claude is a Node.js application.

Running the MITM capture

Interactive mode (two terminals):

Terminal 1:

./claude-mitm.sh proxy    # Starts mitmweb with web UI at http://127.0.0.1:8081

Terminal 2:

./claude-mitm.sh run      # Launches claude routed through the proxy

Browse to http://127.0.0.1:8081 to see decrypted traffic in real time.

One-shot scripted mode (single command):

./mitm-capture.sh "Use the WebFetch tool to fetch https://example.com and report the title."

This starts mitmdump (the CLI version of mitmproxy) with the mitm_logger.py addon, runs one claude prompt through it, and writes a plaintext transcript to mitm.txt.

Reading the decrypted transcript

grep '^>>> ' mitm.txt    # Every outbound request
less mitm.txt             # Full headers and bodies

Here’s a real captured WebFetch request :

>>> GET https://example.com/
    Accept: text/markdown, text/html, */*
    Accept-Encoding: gzip, compress, deflate, br
    User-Agent: Claude-User (claude-code/2.1.168; +https://support.anthropic.com/)
    Host: example.com
<<< 200 OK  (example.com)
    Content-Type: text/html
    ...
--- response body ---
<!doctype html><html lang="en"><head><title>Example Domain</title>...

What the decrypted traffic reveals

With the TLS removed, we can now see exactly what Claude sends and receives. A few things stand out:

  1. User-Agent: WebFetch identifies itself as Claude-User (claude-code/<version>; +https://support.anthropic.com/). Target servers can identify these requests.
  2. Content preference: The Accept header lists text/markdown first. claude asks the server for markdown before HTML because markdown is more useful to a language model than raw HTML.
  3. Direct connection: The request goes directly to example.com, confirming the SNI evidence from the encrypted traffic analysis. There is no intermediate Anthropic proxy.
  4. API calls: You’ll also see POST https://api.anthropic.com/v1/messages. These are the model inference calls containing your prompt, tool results, and the model’s responses.
  5. Telemetry: Requests to http-intake.logs.us5.datadoghq.com carrying usage metrics.

Security warning. mitm.txt contains your decrypted API authorization token in plaintext. Look for the Authorization: Bearer sk-ant-... header in every POST to api.anthropic.com. This is your Anthropic API key. It can be used by anyone who obtains it to make API calls billed to your account. The mitm.txt file is .gitignore‘d in the project. Delete it when you’re done: rm mitm.txt. Treat it with the same care as a credential dump.

Inspecting Claude Code’s Network Traffic with Linux Namespaces and MITM Proxying (Part 2)

[part 1] Overview

[part 2] Isolating Claude’s traffic with network namespaces

[part 3] Extracting hostnames from encrypted traffic

[part 4] Decrypting HTTPS with a MITM proxy

[part 5] Classifying all of Claude’s network connections & Analyzing traffic by category

In Part 1, we introduced the idea that coding agents like Claude Code may chose to scrape using a direct request from the host and a proxied request via Anthropic Infrastructure. In this post, we’ll start isolating traffic for an application using namespaces.

Isolating Claude’s traffic with network namespaces

The concept

We use a network namespace to filter out all networking traffic on the host that is unrelated to the claude process. A namespace gives a process its own isolated copy of the Linux network stack. The process in the namespace gets its own interfaces, routing table, iptables rules, and socket space, completely separated from the host. Because claude is the only process inside the namespace, every packet on that namespace’s interface belongs to claude. No filtering or guesswork required.

Example analogy:

Putting the process in a private interrogation room with one-way glass. The process can still make phone calls (network access via NAT), but every call goes through a single monitored line. Nothing else in the building uses that line, so the wiretap is perfectly clean.

Where this analogy breaks down: In a real interrogation room, the person knows they’re being watched. In a network namespace, the process has no way to detect that it’s isolated. It sees a normal network stack with a normal interface and normal routes. The process behaves exactly as it would in the real environment, which means your observations in the namespace are faithful representations of how the process works.

Mental Model Check: “Aren’t namespaces a Docker thing?”

Docker uses namespaces: network namespaces, PID namespaces, mount namespaces, and more. But namespaces are a kernel primitive, not a Docker feature. They’ve existed since Linux 2.6.24 (2008), years before Docker. You can create and manage them directly with ip netns and unshare, no container runtime needed. What we’re doing here is using them at the lowest level (Just the network namespace, nothing else) to get the isolation we need for traffic capture for the process.

The plumbing: veth pairs

We use a veth pair to connect the namespace to the host so claude can still reach the internet. A veth pair is a virtual ethernet cable with two endpoints. Packets entering one end emerge from the other. We place one end inside the namespace (where claude runs) and keep the other end in the host namespace (where we capture). This dedicated link is the only path claude’s traffic can take, which is what makes the capture clean.

Example analogy:

A private FaceTime call between two people. Everything said on the call is only heard by those two participants. No one else’s conversation is mixed in. If you record the call, you capture exactly that conversation and nothing else.

Where this analogy breaks down: A FaceTime call traverses real networks and can suffer latency, jitter, or dropped packets. A veth pair is a kernel data structure where packets pass between the two ends with no measurable delay or loss. It behaves like a direct wire, not a network connection.

┌──────────────────────────┐      ┌──────────────────────────┐
│     Host namespace       │      │    claudesbx namespace   │
│                          │      │                          │
│  ┌──────────────┐        │      │        ┌──────────────┐  │
│  │  veth-host   │◄━━━━━━━╋━━━━━━╋━━━━━━━►│   veth-ns    │  │
│  │ 10.200.1.1   │ veth   │      │  veth  │ 10.200.1.2   │  │
│  └──────┬───────┘ pair   │      │  pair  └──────────────┘  │
│         │                │      │                          │
│   iptables NAT           │      │     claude process       │
│   (MASQUERADE)           │      │     runs here            │
│         │                │      │                          │
│  ┌──────┴───────┐        │      └──────────────────────────┘
│  │  eth0/wlan0  │        │
│  │  (real NIC)  │        │
│  └──────────────┘        │
│                          │
└──────────────────────────┘

We capture at veth-host (the host side of the virtual cable) because every packet claude sends must cross this interface to reach the internet, and no other process’s traffic uses it. That gives us a clean, process-scoped capture with no filtering required.

NAT: Giving the namespace internet access

The namespace gives claude a private IP (10.200.1.2) that doesn’t exist on the real network, so claude can’t reach the internet without help. We set up NAT masquerading to bridge that gap. NAT masquerading is the same mechanism your home router uses for NAT. Outbound packets from 10.200.1.2 get rewritten with the host’s real IP as the source, and return traffic gets translated back. This gives claude full internet access while keeping all its traffic confined to the namespace’s interface where we can capture it.

Three pieces need to be in place for this to work:

  1. IP forwarding (sysctl net.ipv4.ip_forward=1) tells the kernel to route between interfaces
  2. MASQUERADE rule: rewrite the source IP on outbound packets
  3. FORWARD rules: allows traffic to flow between the veth and the real NIC

Mental Model Check: “NAT is just one iptables rule.”

A word of warning: treating NAT as a single iptables rule is a common misconception that causes silent failures. MASQUERADE handles address rewriting, but the kernel won’t route packets between interfaces unless IP forwarding is enabled (net.ipv4.ip_forward), and the default iptables FORWARD policy is often DROP. You need all three: the sysctl, the NAT rule, and the FORWARD ACCEPT rules. Miss any of these changes and the namespace has no connectivity. It drops packets with no error message.

I have a helpful script for creating a network namespace for use with claude called claude-sandbox.sh

claude-sandbox.sh up does the following work, step by step.

Create the namespace an empty, isolated network stack:

ip netns add claudesbx

Create the virtual cable (both ends start in the host namespace)

ip link add veth-host type veth peer name veth-ns

Move one end into the namespace (this is the crucial step)

ip link set veth-ns netns claudesbx

Configure the host side (this becomes the capture point.

ip addr add 10.200.1.1/24 dev veth-host
ip link set veth-host up

Configure the namespace side- this is the process’s gateway

ip netns exec claudesbx ip addr add 10.200.1.2/24 dev veth-ns
ip netns exec claudesbx ip link set veth-ns up
ip netns exec claudesbx ip link set lo up
ip netns exec claudesbx ip route add default via 10.200.1.1

DNS: point the namespace at Cloudflare’s resolver

mkdir -p /etc/netns/claudesbx
echo "nameserver 1.1.1.1" > /etc/netns/claudesbx/resolv.conf

NAT: masquerade namespace traffic out the real interface

sysctl -wq net.ipv4.ip_forward=1
iptables -t nat -A POSTROUTING -s 10.200.1.0/24 -o eth0 -j MASQUERADE
iptables -A FORWARD -i veth-host -o eth0 -j ACCEPT
iptables -A FORWARD -i eth0 -o veth-host -m state --state RELATED,ESTABLISHED -j ACCEPT

Note: The script dynamically detects your uplink interface rather than hardcoding eth0. The actual line is: UPLINK="$(ip route show default | awk '/default/ {print $5; exit}')".

We can configure DNS separately for the namespace because Linux automatically binds /etc/netns/<name>/resolv.conf into it. The kernel’s namespace implementation includes this mount namespace integration, so processes inside the namespace see their own DNS configuration without any explicit mount commands.

Running the capture (privileged path)

This is a two-terminal workflow:

Terminal 1: bring up the namespace and start capturing:

sudo ./claude-sandbox.sh up
sudo ./claude-sandbox.sh sniff    # writes claude.pcap, prints live DNS + TLS SNI

Terminal 2: run claude inside the namespace:

sudo ./claude-sandbox.sh run

Use claude normally Ask it to fetch a URL, run a web search, anything. Watch Terminal 1 for live hostnames. When done:

sudo ./claude-sandbox.sh down     # removes namespace, NAT rules, veth

The rootless alternative

If you don’t want to use sudorootless-capture.sh achieves the same isolation using user namespaces:

The rootless-capture script is a helper script that invokes claude and activates packet capture for the namespace without requiring root. You pass it a prompt and it handles orchestrating the creation of the namespace for claude & corresponding packet captures.

 
./rootless-capture.sh "Use WebFetch to fetch https://example.com and tell me the page title."

This creates a user+net+mount namespace as your normal user, attaches slirp4netns for userspace networking, and captures with a custom raw-socket sniffer (nssniff.py).

Why a custom sniffer instead of tcpdump? Inside a --map-root-user namespace, the kernel denies setgroups(). tcpdump insists on dropping privileges at startup (calling setgroups), so it exits without capturing anything. Even with -Z rootnssniff.py opens an AF_PACKET socket and never drops privileges. It writes standard pcap format that any tool can read.

tcpdump’s privilege-dropping behavior is a security feature in normal contexts. It prevents a packet capture process from retaining root powers. But inside a user namespace where setgroups is blocked by design, that security feature becomes a compatibility obstacle. The custom sniffer exists because the “right” behavior in one context is the “broken” behavior in another.

What you now have

Checkpoint: After either path, you have claude.pcap. This is a packet capture containing only claude’s network traffic. This is the foundation everything else builds on.

In Part 3, we’ll explore extracting hostnames of the connection source and destinations from encrypted traffic.

Inspecting Claude Code’s Network Traffic with Linux Namespaces and MITM Proxying (Part 1)

This is an analysis of how Claude Code initiates web scraping requests. You might be surprised to find that claude code could scrape from an API endpoint, or from the host that’s running claude code. In this series, you will learn how to use network namespaces, tcpdump and MITMproxy to isolate a client’s network traffic and experimentally discover whether claude code is scraping content from an api endpoint or from the host that’s running claude code. Code for this project is available at my github.

You installed Claude Code. It ships with built-in network tools (WebFetch & WebSearch) that can reach out to the internet on your behalf. Each use requires your explicit approval (unless you’ve configured auto-approve policies). But here’s the question nobody answers in the documentation:

What exactly does it send and where is it sending requests from?

Inspecting Claude Code’s Network Traffic with Linux Namespaces and MITM Proxying

In the proxied fetch model, the request would route through Anthropic’s servers. The target system sees Anthropic’s IP, and Anthropic’s infrastructure handles the fetched content:

These two models have very different privacy and security implications. Which one does Claude Code actually use? This guide answers that question empirically.

When claude code “searches the web,” whose IP address does the search engine see- yours or Anthropic’s? What telemetry does it send home?

These aren’t hypothetical concerns. If you’re running an AI agent that has built-in network tools on a machine that can reach internal services, you need to know its traffic pattern. This guide shows you how you can discover this behavior directly and what I found during my analysis.


What you’ll learn

By the end of this guide, you’ll be able to:

  • Isolate a single process’s network traffic on Linux using network namespaces: this technique ensures you won’t have other process’s packets in the capture
  • Extract destination hostnames from encrypted TLS traffic using SNI and DNS metadata
  • Decrypt HTTPS traffic for a specific process using MITM proxying with mitmproxy, scoped so only that one process is affected
  • Classify all traffic the Claude CLI generates: API calls, web fetches, web searches, telemetry, and MCP connections
  • Understand the security implications of a local-fetch model: What it means for your LAN and internal services

Prerequisites

You should be comfortable with:

  • Linux terminal and shell scripting
  • Basic networking concepts (IP addresses, ports, TCP, DNS)
  • What tcpdump does (even if you don’t use it daily)
  • What TLS/HTTPS provides (encrypted transport)

You’ll need installed:

  • Linux with kernel namespace support (any modern distro)
  • tcpdumpiproute2 (ip command), iptables
  • Python 3 (for the analysis scripts)
  • mitmproxy (pipx install mitmproxy or pip install mitmproxy) for the decryption sections
  • The claude CLI on your PATH
  • slirp4netns for the rootless approach to collecting packets (available in most distro repos)
git clone git@github.com:CaptainMcCrank/claude-netns.git
cd claude-netns
chmod +x claude-sandbox.sh claude-mitm.sh rootless-capture.sh mitm-capture.sh

Key Concepts Before We Start

This guide relies on Linux networking and TLS concepts that may be unfamiliar or counterintuitive.

There is no per-process packet capture on Linux. Linux’s packet capture mechanism (AF_PACKET) attaches to interfaces, not processes. There is no tcpdump --pid. If you capture on an interface shared by every process on the box, you get everything mixed together. The first half of this guide builds a workaround for this using network namespaces.

HTTPS does not hide all metadata. TLS encrypts the payload. The payload includes the URL path, headers, and body. But the destination hostname leaks in plaintext via the TLS SNI extension in every ClientHello, and DNS queries leak it again via standard unencrypted lookups. You can’t read the content, but you can see where traffic goes. That partial visibility turns out to be enough to answer the most important question (where does WebFetch connect?) without any decryption at all.

MITM proxying is a standard debugging technique. The same TLS interception mechanism used by attackers on hostile networks is used every day by Burp Suite, Charles Proxy, Fiddler, mitmproxy, and corporate TLS inspection appliances. Despite the fact that these tools have legitimate applications, many corporate security tools treat them as hacking tools. Only perform MITM Proxying on networks and clients you’re authorized to inspect. Do not proceed if you have any doubts about your authorization for such techniques. While this is all legitimate debugging and auditing, there’s no way for a 3rd party to independently distinguish if this work is an attack or authorized. Many corporate security teams configure monitoring tools to alarm when they see tools such as MITM proxy installed.

Where WebFetch actually connects is an empirical question. Does the request go through Anthropic’s servers, or does it connect directly from your machine? This guide answers that with packet captures.


Why a general tcpdump of all traffic fails

The straightforward approach is to capture traffic on your main interface:

sudo tcpdump -i eth0 -n -c 100 -w everything.pcap

Running this while using claude in another terminal produces a capture, but it contains a wall of noise: browser tabs, system update checks, NTP syncs, SSH keepalives, DNS queries from every application on the box. Claude’s traffic is in there somewhere, but finding it requires picking apart hundreds of flows by hand.

You might try filtering by destination:

sudo tcpdump -i eth0 -n host api.anthropic.com -w filtered.pcap

Better- but this requires you to know every destination in advance (e.g. api.anthropic.com). If claude contacts a URL you didn’t predict, you miss it. Additionally- other processes might also contact api.anthropic.com if you have other Anthropic integrations running.

Why this happens

tcpdump filters by network interface, not by process. There is no tcpdump --pid 12345 on Linux. The kernel’s packet capture mechanism (AF_PACKET sockets) attaches to interfaces and your main interface (eth0wlan0, etc.) carries traffic from every process on the system.

Example analogy:

Trying to record one person’s phone calls by tapping the entire building’s phone line. You hear every tenant: browsers, updaters, background services. There’s no way to isolate one voice because they all share the same wire.

We need a way to give claude its own private network interface that carries only its traffic and nothing else. In Part 2, we’ll explore creating a Linux Namespace that enables us to isolate all network traffic for a specific process.

[part 1] Overview

[part 2] Isolating Claude’s traffic with network namespaces

[part 3] Extracting hostnames from encrypted traffic

[part 4] Decrypting HTTPS with a MITM proxy

[part 5] Classifying all of Claude’s network connections & Analyzing traffic by category

Inspecting Claude Code’s Network Traffic with Linux Namespaces and MITM Proxying (Part 3)

[part 1] Overview

[part 2] Isolating Claude’s traffic with network namespaces

[part 3] Extracting hostnames from encrypted traffic

[part 4] Decrypting HTTPS with a MITM proxy

[part 5] Classifying all of Claude’s network connections & Analyzing traffic by category

Extracting hostnames from encrypted traffic

In the last post, we explored isolating traffic using namespaces. Now, we’ll explore pulling hostnames from an encrypted session.

The capture file contains Claude’s isolated traffic, but all of it is encrypted with TLS. We can’t read the contents of any request or response. However, we don’t need decryption to determine if WebFetch connects directly to target websites or routes through Anthropic. If example.com appears as a direct connection from the namespace, WebFetch is local. If only api.anthropic.com appears, it’s proxied.

TLS leaks destination hostnames in two places, and we can extract them from the encrypted capture.

Open the capture:

tcpdump -nr claude.pcap | head -30

You’ll see TCP handshakes, data transfers, and teardowns. But the payloads are encrypted. TLS protects them. You can’t see the URLs being fetched, the headers, or the response bodies because they’re encrypted in HTTPS transactions.

What TLS leaks: SNI and DNS

Even though the payload is encrypted, two pieces of metadata remain visible in plaintext:

  1. Server Name Indication (SNI): During the TLS handshake, the client sends the server hostname in cleartext as part of the ClientHello message. This is necessary because multiple HTTPS sites can share one IP address (virtual hosting), and the server needs to know which certificate to present before encryption begins.
  2. DNS queries: Before connecting to any hostname, the process resolves it via DNS. Standard DNS (UDP port 53) is unencrypted.

Example analogy:

TLS is like sending a letter in a sealed, opaque envelope. Nobody en route can read the contents. But the envelope still has the destination address written on the outside- the postal service needs it to deliver the letter. In TLS, the SNI field is equivalent to the delivery address. DNS queries are the phone book lookup you make before writing the address. DNS queries are also visible to anyone watching.

Where this analogy breaks down: With physical mail, you can send letters without a return address. With TLS, the client’s IP address is always visible (it has to be, for TCP to work), and the SNI hostname is structurally required in most configurations. There’s no “anonymous envelope” option in standard TLS. The Encrypted Client Hello (ECH) extension aims to seal the SNI, but it requires server-side support and isn’t widely deployed.

Together, these two metadata leaks tell you where claude connects, even though you can’t see what it sends.

Mental Model Check: “HTTPS hides everything.”

This is the most common misconception about TLS, and it’s important to correct because it affects how you reason about privacy and surveillance. TLS protects the contents of communication. This includes the URL path, headers, body, and cookies. But it does not protect the metadata: which server you’re connecting to (SNI), the IP addresses of both ends, the timing and volume of data transferred, and the DNS lookups that preceded the connection. A network observer who can’t read your HTTPS traffic can still build a detailed profile of which services you use, when, and how much data you exchange.

Extracting hostnames with sni.py

The repository includes sni.py, a pure-Python pcap parser that extracts SNI values from TLS ClientHello messages:

python3 sni.py claude.pcap

Output from a real capture (claude fetching example.com):

  8  api.anthropic.com                  -> 160.79.104.10
  3  docs.mcp.cloudflare.com            -> 104.18.24.159
  1  example.com                        -> 104.20.23.154
  1  http-intake.logs.us5.datadoghq.com -> 34.149.66.137

What this tells us

The output reveals four distinct destinations, each serving a different role:

SNI HostnamePurposeCount
api.anthropic.comModel API: inference requests and responses8
docs.mcp.cloudflare.comA configured MCP server, contacted at startup3
example.comThe WebFetch target: a direct connection1
http-intake.logs.us5.datadoghq.comTelemetry / usage logging1

Look at the third row. example.com appears as a direct TLS connection from the namespace. This means WebFetch initiated a TCP connection to example.com’s IP address from the local machine. The request was not proxied through api.anthropic.com. We can see this from metadata without decrypting a single byte of content.

WebFetch connects directly from your machine. Everything that follows confirms and enriches this finding, but the SNI evidence is sufficient on its own.

You can also examine DNS queries to confirm:

tcpdump -nr claude.pcap 'udp port 53'

This shows DNS lookups for each of those hostnames, confirming the process resolved them locally.

The partial finding

We now know WHERE claude’s traffic goes. We can see it contacts the target URL directly. But we still can’t see the HTTP request content . For that, we need to decrypt the TLS.

In the part 4, we’ll dig deeper into the details by decrypting HTTPS with MITM proxy.

The text in Claude Code’s “Extended Thinking” output is not authentic.

Claude Code records each session to disk. Those logs include “thinking blocks” — the model’s own reasoning as it works.

I went to inspect that reasoning this weekend and found a signature (600 characters long) and no text.

So I read the docs: https://platform.claude.com/docs/en/build-with-claude/extended-thinking

Some details worth being aware of:

  • Claude encrypts its reasoning into that signature.
  • Anthropic holds the key. Your machine doesn’t receive it.
  • The API hands back a SUMMARY of reasoning, NOT the reasoning itself.
  • Getting the full thinking output requires an enterprise agreement.

Matt Green looked into this and has some more detailed observations on the signature blocks.

This is worth knowing before you promise anyone an audit trail. Also- BEWARE: The “extended-thinking” output from ctrl+o is a summary of Fable/Opus’ thinking. It isn’t the actual thinking that drove the model’s actions in a session- but a summary of the thinking logic. This is like saving a bmp as a .jpeg and then editing the .jpeg and saving it back as a .bmp. The conversion produces data loss. [edit: I originally had the order inverted, which triggered some HN readers. Apologies!]

I’m underwhelmed by how Anthropic is presenting the behavior of their application. If you ever need a record of the logic a used by YOUR AGENT during a session:

  • you can’t produce the logic using the local files. The reasoning logs on your system are not accessible to you.
  • You can log the inputs, the outputs, and the actions of a running Claude code with some scrappy scraping- but even then- it’s not the actual reasoning that drove the agent’s behavior.

And the language in the docs is awfully indirect. If you haven’t had your coffee, you might miss that “extended thinking returns a summary of Claude’s full thinking process”

Screenshot

Performance improvements in Open Source models need to come faster.

Fixing a stuck Ollama runner and building a GPU watchdog

If you self-host LLMs, at some point, you’ll likely experience a stuck GPU consuming significant electricity for long periods of time. This is my summary of discovering the problem & then implementing automation that corrects the issue. I want automation to discover & correct these issues. I don’t want to be the first line of defense against unnecessary electricity consumption.

My Self Hosted Setup

I run a large language model on hardware at home — an Ollama server on a mini PC (AMD Ryzen AI MAX+ 395 with an integrated GPU). Self-hosting gives me privacy, no subscription, and a model that works without an internet connection. The tradeoff is that I’m my own ops team. When something goes wrong, there’s no provider watching for outages.

This weekend, something went wrong.

I noticed the cooling fan on my AI server running at full speed without letting up. I hadn’t been using it- and my family’s use of it is pretty limited. There likely were no active requests for the system. Either the server was compromised and mining cryptocurrency, or a process was hung and burning power for nothing. The system draws up to 85W — leaving it pinned at full power indefinitely would show up on my electricity bill.

I needed to do two things. First, determine whether the machine was doing real work or stuck. Second, build a solution that catches this class of problem automatically: Monitoring for long running fan activity has an unreliable Mean Time To Resolution.

Diagnosis. I checked what the model was doing with ollama ps and found a model stuck in a Stopping... state — it was supposed to unload and free the GPU, but the underlying process never exited. I confirmed the conditions by reading the GPU utilization gauge from the kernel (/sys/class/drm/card1/device/gpu_busy_percent): ~89% busy, ~85W. It had been in this state for roughly 20 hours with zero inference requests. The normal shutdown command (ollama stop) had no effect. A service restart (sudo systemctl restart ollama) cleared it. The root cause is a known Ollama bug where the GPU stays at full utilization with no work to do.

Watchdog. At this point, I confirmed that the Ollama system was hung and needed to be reset. But this problem has happened before! It would likely happen again. I needed to stop relying on my ability to detect unexpected zephyrs emanating off my server. I created a watchdog that alerts me only when this specific failure occurs. The logic of the watchdog needs to be narrow to avoid false positives. The basics of the system are as follows:

  • A cron job samples the system every 5 minutes with two reads: is a model process loaded, and how busy is the GPU?
  • A sample only counts as unhealthy when both conditions are true: a model is loaded and the GPU is at or above 70% utilization. High GPU usage during inference is normal; this targets high usage while idle.
  • The unhealthy state must hold continuously for 15 minutes before the watchdog alerts. That’s long enough to rule out any legitimate request.
  • Any healthy reading resets the counter, so the watchdog only fires on a sustained stuck condition, not a transient spike.
  • When it fires, it sends a push notification to my phone via ntfy.sh with the diagnosis and the one-line fix command.
  • After sending, it suppresses further alerts for one hour. I want one notification- not a stream of them.

There are two design choices worth noting:

  • The watchdog alerts but never restarts the model automatically. A person can tell a stuck runner from a legitimate long job in seconds. I didn’t want automation killing real work.
  • If the alert fails to send, the watchdog doesn’t mark the event as handled, so the next run retries instead of going silent.

An aside for my friends in Telco: I originally planned to send alerts as SMS through a AT&T’s email-to-SMS gateway, but that service was shut down in mid-2025. I switched to ntfy.sh push notifications, which turned out simpler and requires no stored credentials. The telco industry whiffed badly. Push notifications should be carrier infrastructure- but instead it’s an OTT service. The industry could figure out how to route calls and SMS across different networks, but it couldn’t figure out how to route push notifications across them? A pox on all your houses. IMS should have been more than SIP routing!

Watchdog Results

The stuck Ollama runner was discovered, evaluated and reset. Until last week- I ran the risk that a hung process would run silently for 20+ hours in ways that could raise my electricity bill. The system now self-reports within 15 minutes of high usage. When a model pins the GPU again, I’ll get a phone notification with clear guidance on how to fix it, whether I’m at the machine or not. The watchdog is free to run and is very simple. A cron job and two timestamp files that survive reboots.

Cron Setup:

The cron setup lives in /etc/cron.d/ollama-watchdog (a system cron drop-in, not a user crontab). The installer writes it as:

SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
# m h dom mon dow user command
*/5 * * * * USER /opt/ollama-watchdog/watchdog.sh >> /opt/ollama-watchdog/state/watchdog.log 2>&1

A few operational notes:

  • No crontab command involved. Because it’s a drop-in file, you manage it by editing/removing /etc/cron.d/ollama-watchdog directly.
    It’s picked up automatically — no reload needed. File should be mode 644, owned root.
  • Reinstalling (sudo bash /home/$USER/install-ollama-watchdog.sh) rewrites this file each time, so edit the installer if you want to
    change the schedule permanently. For a one-off tweak, edit the cron file directly.
  • Detection cadence vs. alert latency: the 5-min interval sets the resolution. Combined with the 15-min sustain threshold, worst-case
    time-to-alert after a runner wedges is roughly 15–20 minutes (you need ~3 consecutive bad samples to cross 900s). Verify it’s installed and watch it work:
cat /etc/cron.d/ollama-watchdog # confirm the entry
tail -f /opt/ollama-watchdog/state/watchdog.log # watch each 5-min decision

Watchdog Installation Script:

#!/usr/bin/env bash
#
# install-ollama-watchdog.sh
# ---------------------------
# Installs a cron-driven watchdog that pushes a phone notification (via ntfy.sh)
# when an `ollama runner` pegs the GPU for a sustained period (the
# "permaspinning fan" failure seen ).
#
#   - Samples every 5 min (cron).
#   - Alerts only after the bad condition has held continuously for >15 min.
#   - Rate-limits to at most one push per hour while it stays stuck.
#   - ALERT ONLY: it never restarts ollama for you.
#
# Transport: ntfy.sh. The topic name IS the secret/credential, so make it
# deliberately opaque. Subscribe to it in the ntfy app or at
# https://ntfy.sh/<topic>. No account, no API key, no .env needed.
#
# Run with:  sudo bash install-ollama-watchdog.sh
#
set -euo pipefail

BASE=/opt/ollama-watchdog
STATE="$BASE/state"
RUN_USER=_USER_           # cron job runs as this user (owns state/log).  Add your user account!

if [ "$(id -u)" -ne 0 ]; then
  echo "This installer must be run as root:  sudo bash $0" >&2
  exit 1
fi

echo "==> Creating $BASE"
mkdir -p "$BASE" "$STATE"

# ---------------------------------------------------------------------------
# watchdog.conf  (settings; edit thresholds/topic here, then no reinstall)
# ---------------------------------------------------------------------------
echo "==> Writing $BASE/watchdog.conf"
cat > "$BASE/watchdog.conf" <<'CONF_EOF'
# ollama-watchdog configuration (sourced by watchdog.sh)

# --- where the alert goes (ntfy.sh) ---
# The topic name is opaque on purpose: anyone who knows this URL can read AND
# spoof your alerts, and it reveals nothing about what it monitors. Treat it
# like a password. Subscribe to this same topic in the ntfy phone app.
NTFY_URL="https://ntfy.sh/_YOURNTFY.SH_TOPIC"
NTFY_PRIORITY="high"                 # min|low|default|high|urgent (urgent can bypass Do Not Disturb)
NTFY_TITLE="Ollama ALERT"
NTFY_TAGS="rotating_light,fire"      # emoji shown on the notification

# --- detection ---
GPU_BUSY_FILE="/sys/class/drm/card1/device/gpu_busy_percent"
GPU_BUSY_THRESHOLD=70                 # percent; "bad" sample if at/above this
SUSTAIN_SECS=900                      # must stay bad this long before alerting (15 min)
REALERT_SECS=3600                     # min seconds between repeat pushes (1 hr)

STATE_DIR="/opt/ollama-watchdog/state"
CONF_EOF

# ---------------------------------------------------------------------------
# watchdog.sh  (the monitor itself)
# ---------------------------------------------------------------------------
echo "==> Writing $BASE/watchdog.sh"
cat > "$BASE/watchdog.sh" <<'WD_EOF'
#!/usr/bin/env bash
# ollama-watchdog: push a phone alert (via ntfy.sh) when an ollama runner pegs
# the GPU for a sustained period. Alert-only; no auto-remediation. See
# watchdog.conf.
set -uo pipefail
export PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin

CONF=/opt/ollama-watchdog/watchdog.conf
[ -r "$CONF" ] && . "$CONF"

# defaults (overridable in watchdog.conf)
: "${NTFY_URL:=https://ntfy.sh/_YOURNTFY.SH_URL}"
: "${NTFY_PRIORITY:=high}"
: "${NTFY_TITLE:=YOUR_TITLE ALERT}"
: "${NTFY_TAGS:=rotating_light,fire}"
: "${GPU_BUSY_FILE:=/sys/class/drm/card1/device/gpu_busy_percent}"
: "${GPU_BUSY_THRESHOLD:=70}"
: "${SUSTAIN_SECS:=900}"
: "${REALERT_SECS:=3600}"
: "${STATE_DIR:=/opt/ollama-watchdog/state}"

BAD_SINCE="$STATE_DIR/bad_since"
LAST_ALERT="$STATE_DIR/last_alert"
mkdir -p "$STATE_DIR"
now=$(date +%s)

send_push() { # title body
  local title="$1" body="$2"
  curl --silent --show-error --fail \
    -H "Title: $title" \
    -H "Priority: $NTFY_PRIORITY" \
    -H "Tags: $NTFY_TAGS" \
    -d "$body" \
    "$NTFY_URL"
}

# --- test mode: send one push now and exit ---
if [ "${1:-}" = "--test" ]; then
  if send_push "ai.local test" "ollama-watchdog test $(date '+%H:%M'). If you got this, alerts work."; then
    echo "test push sent to $NTFY_URL"; exit 0
  else
    echo "test push FAILED" >&2; exit 1
  fi
fi

# --- sample ---
runner_pid=$(pgrep -f 'ollama runner' | head -1 || true)
gpu_busy=$(cat "$GPU_BUSY_FILE" 2>/dev/null || echo "")

bad=0
if [ -n "$runner_pid" ] && [ -n "$gpu_busy" ] \
   && [ "$gpu_busy" -ge "$GPU_BUSY_THRESHOLD" ] 2>/dev/null; then
  bad=1
fi

if [ "$bad" -eq 0 ]; then
  rm -f "$BAD_SINCE" "$LAST_ALERT"          # healthy: reset
  echo "$(date -Is) ok gpu=${gpu_busy:-NA}% runner=${runner_pid:-none}"
  exit 0
fi

[ -f "$BAD_SINCE" ] || echo "$now" > "$BAD_SINCE"
since=$(cat "$BAD_SINCE" 2>/dev/null || echo "$now")
elapsed=$(( now - since )); mins=$(( elapsed / 60 ))
echo "$(date -Is) bad gpu=${gpu_busy}% runner_pid=${runner_pid} sustained=${mins}m"

[ "$elapsed" -lt "$SUSTAIN_SECS" ] && exit 0   # not sustained 15 min yet

last=0; [ -f "$LAST_ALERT" ] && last=$(cat "$LAST_ALERT" 2>/dev/null || echo 0)
[ $(( now - last )) -lt "$REALERT_SECS" ] && exit 0   # already pushed within the hour

# context for the message
tctl=$(sensors 2>/dev/null | awk '/^Tctl:/{print $2; exit}')
ppt=$(sensors 2>/dev/null | awk '/PPT:/{print $2" "$3; exit}')
model=$(ollama ps 2>/dev/null | awk 'NR==2{print $1; exit}'); [ -z "$model" ] && model="?"

body="ollama pegged GPU ${gpu_busy}% for ${mins}m. Tctl ${tctl:-?} PPT ${ppt:-?}. model=${model}. Fix: sudo systemctl restart ollama"

if send_push "$NTFY_TITLE" "$body"; then
  echo "$now" > "$LAST_ALERT"
  echo "$(date -Is) ALERT sent: $body"
else
  echo "$(date -Is) ALERT send FAILED" >&2
fi
WD_EOF

chmod 755 "$BASE/watchdog.sh"
chmod 644 "$BASE/watchdog.conf"

# state dir must be writable by the cron user (job runs as $RUN_USER)
chown -R "$RUN_USER":"$RUN_USER" "$STATE"

# ---------------------------------------------------------------------------
# cron.d entry  (runs as $RUN_USER every 5 minutes)
# ---------------------------------------------------------------------------
echo "==> Installing /etc/cron.d/ollama-watchdog"
cat > /etc/cron.d/ollama-watchdog <<CRON_EOF
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
# m h dom mon dow user command
*/5 * * * * $RUN_USER /opt/ollama-watchdog/watchdog.sh >> /opt/ollama-watchdog/state/watchdog.log 2>&1
CRON_EOF
chmod 644 /etc/cron.d/ollama-watchdog

echo
echo "==> Installed."
echo "    Monitor : $BASE/watchdog.sh   (every 5 min via /etc/cron.d/ollama-watchdog)"
echo "    Config  : $BASE/watchdog.conf"
echo "    Log     : $STATE/watchdog.log"
echo
echo "Subscribe in the ntfy app to topic:  YOUR_NTFY.SH_URL"
echo "Then send yourself a test push:"
echo "    $BASE/watchdog.sh --test"

Getting Up to Speed on Gastown

Steve Yegge posted about Gastown in January 2026. I read it at the time but didn’t really understand it. Some concepts resonated with me- but I found the language and metaphors of gastown obfuscatory. There are parts of the writing that I associate with LLM-psychosis. I’m disappointed in myself for that reaction. My bias kept me from understanding with what he was actually doing. The nano-banana graphics didn’t help. There’s an uncanny valley quality to them. Whimsical, but slightly off. My mind rushed to label the ideas as lower value because the marmots are for weirdos.

Fast-forward to April: I’d been building my own orchestration solutions around Kanban boards and Claude Code / OpenCode. Something prompted me to re-read the Gastown post in April. I discovered I that had duplicated parallel versions of his orchestration (minus beads, which looks amazing). I didn’t realize what he was doing, and ended up with my own version of this thing. Parallel innovation is the theme of 2026.

If you haven’t read it yet, take some time to read the Gastown post. It’s hard to absorb on the first pass. If you lose all patience, the interview links below will help you get oriented on the important parts without the hyperbole and less dependency on mastery of the metaphors.

————————

From TWiTFull episode

(note: Leo Laporte’s team can wander into tangents that go nowhere. They’re usually easy to spot, but set your own tolerance level going in.)

————————

From AI TinkerersFull episode

————————

Another interview (good material, but heavy on ads): YouTube

————————

Beads quickstart: gastownhall.github.io

UPDATE:

Provocative Interview:
Steve Yegge’s Vibe Coding Manifesto: Why Claude Code Isn’t It & What Comes After the IDE


2026: The Year The IDE Died — Steve Yegge & Gene Kim, Authors, Vibe Coding

https://www.youtube.com/watch?v=7Dtu2bilcFs

Inviting two AI agents to generate a collaboration protocol

Have you ever spent hours of work in a Claude session, realized you couldn’t use that same session for the next phase of the work, and still wanted to preserve the context? What if you could split the task across two agents — without having designed that capability into a preprompt up front?

I want to walk you through a small piece of social engineering between me and two Claude Code agents. It came in the form of a collaboration protocol defined in a markdown file. The file itself matters less than how it got written: the agents helped me write it while they worked on their role-specific tasks.

Here’s how it came together.

Context was running out!

I could see I was running myself into a corner with the complexity of the prompts I was providing.

I was rewriting the opening of a workshop deck I’ve been building. (It’s an impress.js presentation, the kind where slides float around a 2D canvas instead of just paging.) The old opener had a “what if your terminal could think?” hook. I wanted a different opener, one that named a real thesis: prompts are an asset, and recording and iterating them builds a personal library that compounds over time.

Impress.js is beautiful. My workshop content is hard to understand in a single shot. Using a beautiful presentation framework gave me an opportunity to present ideas in a way that would keep the reader engaged.

My objective in the session was to brainstorm different approaches for expressing the ideas presented in the draft of my presentation.

I had lots of bad “first-draft” clips of material in the presentation. I’ve discovered they were bad through occasionally humiliating practice with people who were nice enough to be a test audience. There was some bruised slides that needed to be renovated.

This copy-drafting activity naturally consumed a lot of context. I was asking the LLM to generate 4-5 different versions of the same content. Then I’d move on to other sections of the presentation and repeat. I’d spend most of an afternoon brainstorming language with Claude Code. By the end, the model’s context was full of voice coaching: tone rules, banned phrasings, half a dozen near-final drafts. Rinse and repeat, my gas tank of available Context was rapidly trending towards empty.

I was going to start nudging the agent to edit the presentation html — but my /context size suggested I wasn’t going to get very far. The agent should compact before too much longer. The session summary that would be generated would end up losing important rules. It’s going to end up like a screenshot of a gif saved a a jpeg. A mutant.

You probably can see where this is going. The same agent that had spent four hours iterating on word choice was about to make numerous precise edits to a 2,000-line HTML file. Editing source is a different kind of work than picking metaphors. An agent doing code manipulation needs a lot of free context and extremely pithy context statements. The goal for the agent should be exclusive: place the chosen content in the correct slides in ways that wouldn’t break the on-screen presentation. But here I was- I needed to change tasks to a very different memory management workflow.

What can we do about a foreseeable failure? How can we preserve the brainstorm context?

The states I was trying to cultivate

I find it helps to describe the end states you want to create and the bad end states you’d like to prevent.

States I wanted to produce:

  • A clean context window for the editing work
  • The brainstorming context is preserved
  • A backing repository that was never mid-drift — never in a state where a slide had been updated but the directive content creation docs were stale- we needed to update content creation docs with every edit.
  • An auditable decision log explaining why each change happened

States I wanted to avoid:

  • A saturated agent making source edits
  • Brainstorming work lost the moment I closed the chat
  • Edits to a slide before the syllabus and teacher’s guide were updated to match the content
  • A change in the repo with no recorded reason. Future-me does not enjoy reconstructing intent by inspecting diffs.

Almost everything in these lists is about context. What if we could have multiple agents leveraging shared context?

What you’d actually need to try this

If you wanted to reproduce this experience, you’d need four things:

  1. Roles: Each agent announces what it is on its first message. (“Acting as Drafter.” “Acting as Editor.”)
  2. Role-specific Turf, split by directory. Each agent gets their own directory. We define “turf” and specify that Crossing turf without permission is a violation. Agents have definition statements that express what is approved and disallowed behavior. We tell the agents to be good by only writing in their directory. We tell them bad agents try to write in the other agents directory. We tell the agent: “don’t be a bad agent!” (Evolved developers will use sandboxing). We establish a concept of turf, as a mechanism for making the agents distinct.
  3. A unit of handoff. The agents would produce one markdown file per atomic edit, with a known structure: what the current text looks like, what it want to change it to, and why. The structure isn’t significant. What matters is that handoffs are small, named, and reviewable.
  4. A human bus. The two agents can’t talk to each other. The human is the message bus. You paste a one-line “HANDOFF: 3 drafts proposed” from one agent into the other.

Obviously, if I had come up with this idea in advance of the session, I could have proposed a shared file system location for managing the handoffs. This example is a summary of how to back yourself out of a corner when context has gotten low.

The lifecycle, the verification rules, the conflict resolution all grew out of these four primitives as I used them. I didn’t design them up front. They were a mushroom growing on some scaffolding.

Agents don’t self-approve

There are 5 possible artifacts states in this newly developed collaboration protocol:

  • Handoff
  • Proposed
  • Approved
  • Applied
  • Blocked

I’m the Operator. I am the man-in-the-middle! Neither agent can mark its own work as done. The Drafter Agent writes a proposal and sets its status to proposed. Only the operator can move a proposal to approved. The Editor Agent will not touch a draft that isn’t approved. After the Editor agent applies it, it sets the status to applied and records the commit hash that landed the change.

There’s a fifth status, blocked. It means the Editor agent opened a draft, looked at the live source file, and noticed the source had shifted since the Drafter agent wrote the proposal. It could be that a different draft already changed adjacent text. Rather than apply something that no longer fits, the Editor agent sets the status back to blocked with a note. The Drafter agent has to re-read the file and revise.

The Drafter agent discovers that its work is now stale, but it doesn’t decide whether to ship. The operator will need to intervene.

The agents helped me write the protocol

I didn’t write the protocol up before the session. I drafted a first version defining concepts of turf, draft format & draft status lifecycle and put it in a file called v1-edit-protocol.md. Both agents read it at the start of every turn. As I used it and the protocol evolved.

The Editor agent, on its third or fourth applied draft, noticed it was hitting an ambiguous case: a draft of proposed content would result in a layout change to a slide. The Editor agent had no way to tell whether the layout would actually look right when rendered.

The Editor Agent observed, “I can’t verify this without rendering the deck. Do you want me to flag layout-changing drafts in pre-review?” 

That interaction with the operator resulted in the creation of the visual-layout-verification rule, which is now enshrined in the protocol file.

The Drafter agent noticed that drift in its source documents wasn’t reliably being discovered. I was at fault. Sometimes I’d remember to check whether a slide change needed a corresponding update in the syllabus. Sometimes I’d forget. It proposed a stricter formulation: every draft has to declare what it searched for and discovered in each source document during an edit. If there was a different result than previous versions, it implied an outside party (likely the operator) tampered with source materials.

I carried each of these rule proposals between agents. The other agent would read the new rule, push back if it didn’t make sense, and we’d land on something both agents could follow. Then I’d update the protocol file, and both agents would pick up the new version on their next read.

You can dynamically give multiple agents a mechanism to interact, and the interaction mechanism can be evolved by agents.

You might not need a multi-agent framework, a router, or an orchestrator, or a fancy preprompt that anticipates every situation.

You might be able to get away with a shared markdown file, a turf table, and a willingness to let the agents discover where the contract needs to grow.

Use Case: Rendering & Observation

Here’s the layout-verification rule the Editor proposed, in the form it eventually took.

When a draft changes anything about how a slide is positioned, the Drafter Agent has to render the deck, click through the affected region, and write a one-sentence observation into the draft’s rationale. Something like: “Verified rendering at the local server. The 500-unit gap between row 1 and row 2 produced visible overlap. Adjusted to a 1,000-unit gap and reverified.”

This observation exists because impress.js layouts can fail visually in ways that can’t be caught by agent driven tests. E.g. Two slides can technically be at valid coordinates and still overlap on screen. I’ve only been able to discover these bugs by being a human reviewer in the loop. Writing tests for validating human usability of a canvas UI element is Very Hard.

The Editor agent didn’t try to solve the layout problem with the observation. It recognized that a known layout failure condition had been identified flagged the gap to me, and we collaborated to co- write a rule that pushed verification to the editor agent. I hadn’t defined a way to embed this validation activity into the editor agent. The editor agent discovered it needed to be able to check its own work- and since it can’t change its own rules, it worked with me to create new role definitions that closed the gap. The protocol grew exactly where it needed to.

When two agents want the same file

Conflicts are rare because the turf table prevents most of them. The agents only write in their own directories. For the cases the table doesn’t cover:

  • Editor wins for presentation/ (the source files)
  • Drafter wins for .drafts/ (proposals)
  • Anything else is mine to resolve

If I edit a file directly outside the protocol — patch a typo, fix a broken link, whatever — I announce it with a HANDOFF line so both agents re-read the file before their next operation.

What this looks like on disk

If you cloned the repo right now and poked at it, you’d see the artifacts of the protocol:

# All the proposals, one per atomic edit
$ ls .drafts/ | wc -l
67
# Every Editor commit that landed a change
$ git log --oneline --grep "^content:"
1090b0f content: rewrite Topic 9 Explain to use relative symlink pattern
a48bb30 content: rewrite Topic 1 Tell to lead with library thesis
# Every Drafter commit (proposals, never source files)
$ git log --oneline --grep "^drafts:"
# Anything currently kicked back to the Drafter
$ grep -l "status: blocked" .drafts/*.md
# Trace any applied draft to the commit that landed it
$ grep -l "applied_commit: 1090b0f" .drafts/*.md
.drafts/slide-09-explain-symlink-rewrite.md

Each draft links to its git commit hash. Each commit links back to its draft in the message body. The commit history becomes a ledger of decisions, with rationale.

The key takeaways from this weird experiment in ad-hoc collaboration protocol establishment:

The default mental model for most people’s agent work is one human, one agent reflected in one conversation. 

When an LLM conversation gets too long or too saturated, you have to start over.

You compact the session summary and lose detail about what has been done. You push through your compressed session and accept degraded LLM output. It’s lossy and degrades over time.

This is context degradation is why Browser-based use of LLMs are a dead end.

They keep builders tethered to the ground. You need agents that interact with version controlled artifacts that can help establish and maintain context.

Your mental model should be: interactions with LLMs produce boundaried, artifact-driven workflows.

Spin up multiple agents. Define and distribute turf amongst them. Let them read a shared file at the start of every turn. Operate as the message bus by copying messages between the agents. And (this part feels weird) when the protocol has a gap, ask the agents to help you fill it. Agents notice the gaps faster than you do.

After you establish an operating pattern that works, evolve the agents to writing the prompts you’re pasting to a shared file. Remove yourself from the loop- and focus on approving or rejecting change requests from the agents.

You’re collaborating with agents who are also collaborating with each other, through a contract you collaboratively maintain. The contract can be a markdown file that evolves every day. The agents can collaborate with the operator to add a new section to the contract.

The foundational rule is: Every agent reads the collaboration contract before acting.