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 Type
Destination
Origin
Encrypted
Contains
WebFetch
Target URL directly
Your machine
Yes (TLS)
Full HTTP GET with Claude-User UA
WebSearch
Observe and document
Observe
Yes (TLS)
Search queries and results
Model API
api.anthropic.com
Your machine
Yes (TLS)
Prompts, tool calls, model responses, API key
Telemetry
*.datadoghq.com
Your machine
Yes (TLS)
Usage metrics
MCP
Configured servers
Your machine
Varies
MCP 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
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:
Network namespace isolation: Run the agent in a namespace with controlled egress. Capture and audit all traffic.
Egress filtering: Allowlist specific destinations rather than blocklisting RFC1918 ranges. If the agent only needs to reach specific APIs, restrict it to those.
DNS-based control: Use a controlled DNS resolver in the namespace that only resolves permitted domains.
Proxy-based inspection: Route all agent traffic through a forward proxy with URL allowlisting. This gives you both filtering and a complete audit log.
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.
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)
Claude connects to mitmproxy (thinking it’s the real server)
mitmproxy presents a certificate it generated on-the-fly, signed by its own CA
Claude accepts this certificate because we told Node.js to trust that CA
mitmproxy reads the plaintext request
mitmproxy connects to the real server and forwards the request
The real server responds; mitmproxy reads the response
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
With the TLS removed, we can now see exactly what Claude sends and receives. A few things stand out:
User-Agent: WebFetch identifies itself as Claude-User (claude-code/<version>; +https://support.anthropic.com/). Target servers can identify these requests.
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.
Direct connection: The request goes directly to example.com, confirming the SNI evidence from the encrypted traffic analysis. There is no intermediate Anthropic proxy.
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.
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.
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.
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:
IP forwarding (sysctl net.ipv4.ip_forward=1) tells the kernel to route between interfaces
MASQUERADE rule: rewrite the source IP on outbound packets
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
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 sudo, rootless-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 root. nssniff.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.
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
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.
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 (eth0, wlan0, 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.
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:
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.
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):
The output reveals four distinct destinations, each serving a different role:
SNI Hostname
Purpose
Count
api.anthropic.com
Model API: inference requests and responses
8
docs.mcp.cloudflare.com
A configured MCP server, contacted at startup
3
example.com
The WebFetch target: a direct connection
1
http-intake.logs.us5.datadoghq.com
Telemetry / usage logging
1
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.
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.
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 loadedand 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"
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.
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:
Roles: Each agent announces what it is on its first message. (“Acting as Drafter.” “Acting as Editor.”)
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.
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.
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 DrafterAgent 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.
What does it mean to turn a Linux system into networking infrastructure?
I think it is incredibly cool that we can change a Linux system into a networking device. But have you ever wondered:
What are we changing when we turn a Linux system into a router or switch?
What are we changing if we make a raspberry pi into a WiFi access point?
How significant is the system performance monitoring change?
What are the gates we have to change to enable packet forwarding and processing?
I’m going to start out with a narrative explanation of the changes that turn a Linux system into a WiFi access point and then I’ll show the commands for implementing it.
I have a cognitive bias: I think of networking devices and computers as different things. This is because the command line experience on networking gear is different than what you experience on servers/hosts. On servers and workstations: you tend to focus a lot on objects on the file system. On networking gear, you’re spending most of your time working with running processes directly. Commands and interaction objectives on networking gear is very different than those on hosts.
I suspect a lot of other people who have worked in networking have similar feelings about networking appliances versus host operating systems. This might be specific to my journey. But for better or worse, I felt that networking was different than general computing. It isn’t. If you know networking, you can make Linux do networking things if you make 7 changes.
Activating IP Forwarding
Defining The Bridge
Activating nftables policies
Stateful Firewalling with conntrack
Defining NAT and Masquerade policies
Vending DHCP and DNS with dnsmasq
Vending WiFi networks with hostapd
To activate packet processing and forwarding in the Linux Kernel, you start by changing the Kernel’s configuration for networking. Every Android device that vends a personal WiFi hotspot makes the same general changes.
A packet’s journey through the kernel
Let’s assume we have a Linux machine with a single network interfaces. A packet arrives on the externally facing interface. The Network Interface Card (NIC) signals an interrupt and the driver pulls the frame into a ring buffer in kernel memory via Direct Memory Access (DMA), where the hardware writes data into RAM without Central Processing Unit (CPU) involvement. The kernel’s networking stack picks the frame up from there, strips the Ethernet header, and examines the Internet Protocol (IP) destination address.
At that point the kernel consults its routing table. If the destination address matches one of the machine’s own interfaces, the packet travels up through the network stack to a listening socket, to a process waiting to handle it. If the destination address matches no local interface and IP forwarding is disabled, the kernel drops the packet and increments a counter in /proc/net/snmp.
The default behavior of Linux is the end of the line for a packet: the kernel cannot forward the packet to another host. We need to make changes to the system if we want to enable routing. We also need another nic to send across network interfaces. A workstation is a host, not a router.
Now imagine that same system with two NICs (aka dual-homed)- how do we get closer to routing packets?
A router’s role is to forward the packets our single-homed host drops by default. Let’s explore each of the steps that move the kernel from a workstation’s conservative posture as a host into a router that routes packets, modifies packet headers, and filters traffic between interfaces.
What is a hook?
In the Linux kernel, a hook is a designated interception point in a code path where external functions can register themselves to execute. Think of it as a slot in an assembly line: the main process pauses at predefined points and runs every function that has registered at that slot, in priority order. Each registered function can inspect, modify, accept, or drop the item passing through. Hooks let the kernel separate its core packet-processing logic from policy decisions like filtering and address translation. The kernel defines where the hooks are; administrators and tools like nftables decide what code runs at each one. The kernel implements hooks as arrays of function pointers stored in structures like struct nf_hook_entries. At each hook point, the kernel iterates the array via nf_hook_slow(), passing each registered callback a pointer to the packet’s sk_buff structure.
Earlier, I made reference to “The kernel’s networking stack.” Just what does that mean?
A packet arrives at the NIC. The driver places it in memory and the kernel’s networking stack processes it through several ordered stages. At defined points along this path, the kernel passes the packet through netfilter, a hook-based framework built directly into the kernel’s networking code.
Netfilter hooks are function pointer arrays registered inside the kernel’s packet processing path. At each hook point, the kernel iterates through every registered function in priority order, passing a pointer to the packet’s socket buffer (sk_buff). Each registered function can accept, drop, modify, or queue the packet. Userspace tools like nftables register callback functions at these hooks by sending commands through a netlink socket, a kernel-userspace Inter-Process Communication (IPC) channel designed for networking configuration.
You can observe netfilter’s activity at runtime. nft list ruleset shows all currently registered tables and chains. conntrack -L shows the live connection tracking table. For deeper inspection, perf trace or bpftrace can attach probes to kernel functions like nf_hook_slow (the function the kernel calls when it iterates hook callbacks), letting you watch individual packet decisions in real time.
The five standard hook points are:
Hook
Position in the packet path
PREROUTING
Immediately on arrival, before any routing decision
INPUT
For packets destined for a local process
FORWARD
For packets passing through the machine to another host
OUTPUT
For packets generated by local processes
POSTROUTING
Just before a packet leaves an interface
After PREROUTING, the kernel makes its routing decision. Packets addressed to the machine itself travel up through INPUT. Packets addressed to other hosts, when forwarding is enabled, move to FORWARD and then out through POSTROUTING. Every configuration step either registers code on one of these hooks or changes how the routing decision behaves.
Change 1: Activating IP Forwarding
IP forwarding is the first gate for enabling transport of packets across interfaces. Without it, the FORWARD hook might exist, but the kernel never sends packets to it. Packets arriving for foreign destinations die after the routing lookup. With it open, the kernel hands those packets to FORWARD, and every other piece of the router configuration takes effect.
You manage ip forwarding through the /etc/sysctl.d/10-forward.conf file:
/etc/sysctl.d/ is a drop-in configuration directory for kernel runtime parameters. At boot, systemd-sysctl.service reads every *.conf file in that directory (plus /etc/sysctl.conf) and writes each parameter to its corresponding path under /proc/sys/.
The kernel exposes a virtual filesystem at /proc/sys/ where every tuneable parameter appears as a file. The dotted sysctl notation is just a path translation: net.ipv4.ip_forward maps to /proc/sys/net/ipv4/ip_forward. Writing 1 to this file tells the IPv4 stack to send packets with non-local destinations through the FORWARD hook rather than discarding them. The kernel implements this decision in ip_forward() in net/ipv4/ip_forward.c.
Writing 1 to sysctl.d/10-forward.conf makes those writes persistent across reboots.
systemd-sysctl.service reads all files under /etc/sysctl.d/ at boot and applies them in lexicographic order. Restarting the service applies them immediately without requiring a system reboot. You can verify the active value at any time:
cat /proc/sys/net/ipv4/ip_forward
1 means forwarding is live. 0 means the gate is closed, and the rest of the router configuration is inert regardless of what else is configured.
Our first change is setting the kernel’s ip_forward parameter to 1.
Change 2: Defining The Bridge: Collapsing Two Interfaces Into One Segment
A home network serves both wired and wireless clients on the same subnet. The configuration creates a network bridge, br0, and attaches eth0 and wlan0 to it as member ports. For details on Linux bridge interfaces, see the kernel bridge documentation.
Our second change is defining a bridge and adding interfaces to it that bind them for passing packets.
A bridge operates at Layer 2, the Ethernet layer. The kernel’s bridge module maintains a Media Access Control (MAC) address forwarding table. When a frame arrives on eth0, the bridge looks up the destination MAC address in that table and forwards the frame to the port where that address was last seen. If the address is unknown, the bridge floods the frame to all member ports. The bridge expires learned associations after a configurable aging time. To the rest of the network, br0 appears as a single unified switch, one shared Layer 2 segment across both wired and wireless interfaces. The kernel implements bridge forwarding logic in br_forward() in net/bridge/br_forward.c.
This matters for routing because the kernel assigns IP addresses to interfaces, not to physical ports. Assigning 192.168.1.1 to br0 means the router holds a single Local Area Network (LAN) address regardless of whether a client is wired or wireless. Both interfaces carry traffic on the same subnet and communicate at Layer 2 without any routing decision required between them.
One important distinction: a wired interface like eth0 is enslaved to the bridge directly with a single command (ip link set eth0 master br0), and the kernel’s bridge module immediately begins learning MAC addresses from frames arriving on it. A wireless interface (wlan0) cannot be enslaved to the bridge this way.
The 802.11 protocol requires an association and authentication lifecycle that standard Ethernet bridging doesn’t account for. Instead, hostapd manages this relationship: the bridge=br0 directive in hostapd.conf instructs hostapd to attach wlan0 to the bridge once the interface is in AP mode. Wireless clients that associate with the AP are then visible to the bridge as if they were on a wired port. The result is the same unified L2 segment, but the path to get there is different for wired and wireless members.
The mac80211 subsystem moves all aspects of master mode into user space. It depends on hostapd to handle authenticating clients, setting encryption keys, establishing key rotation policy, and other aspects of the wireless infrastructure. Due to this, the old method of issuing iwconfig <wireless interface> mode master no longer works
On a standard Ethernet bridge port, any device that sends a frame gets its MAC learned — there’s no prior handshake required at L2. On an 802.11 AP, the MAC layer itself enforces that a client must complete authentication and association (State 3) before the AP will accept or forward its data frames. The AP’s MAC (managed by the driver via mac80211) is the gatekeeper, and it needs a userspace daemon (hostapd) to handle the authentication exchanges. The kernel’s bridge module has no knowledge of 802.11 states — it just sees frames — so it can’t manage this lifecycle on its own.
The bridge-utils package provides brctl for inspecting bridge state. The kernel handles all forwarding logic through the br_netfilter and bridge modules.
Aside: bridges and packet capture. A bridge port is an excellent place to insert a packet capture. Attach a third interface to br0 and mirror traffic to a tap device (for more on tap/tun virtual interfaces, see the kernel tuntap documentation), or use a standalone bridge with a port set to promiscuous mode feeding a capture daemon like tcpdump or Zeek. Because the bridge sees all frames on the segment before any routing or filtering decision, a capture at this layer sees the complete pre-Network Address Translation (NAT), pre-firewall traffic picture. Tools like tcpdump -i br0 or an AF_PACKET socket bound to the bridge interface work at line rate for most home and small-business traffic volumes. These tools max out on a default Linux kernel at around 18 Gbps (at least they did when I last tested them, around 2023). Higher line rates require tools with hardware-based filtering like the Data Plane Development Kit (DPDK) or eXpress Data Path (XDP).
Change 3: Activating nftables policies: Installing Code on the Hooks
Now that we have a bridge, we need to define packet processing rules via netfilter’s nftables.
Netfilter is the broader kernel-level packet filtering framework that provides the hooks into the network stack, while nftables (via nf_tables) is the modern packet classification engine that operates on top of those hooks. It replaced iptables as the preferred interface, but both ultimately rely on the same netfilter hook infrastructure in the kernel. The kernel implements the nf_tables subsystem in nf_tables_api.c in net/netfilter/.
The firewall and NAT rules in /etc/nftables.conf are callback registrations. nftables sends them to the kernel through a netlink socket, and the nf_tables subsystem installs them at the specified hooks. Each chain declaration names its hook and priority explicitly:
This chain controls traffic forwarding between interfaces, the core job of a router. Here’s what’s happening:
The chain definition:
type filter hook forward priority 0; policy drop;
This attaches to netfilter’s forward hook, meaning it only sees packets that aren’t destined for the router itself but need to pass through it. The default policy is drop, so anything not explicitly allowed is silently discarded. This is a deny-by-default posture.
In this WiFi AP setup, eth0 is the WAN-facing interface — the uplink to your ISP or upstream router. br0 is the LAN-facing bridge, which aggregates traffic from wired clients (if any are directly attached) and wireless clients managed by hostapd. All LAN traffic enters and exits through br0, regardless of whether it originated from a wired or wireless device. With that topology in mind, the two rules in the FORWARD chain map directly to the two directions of traffic flow across the router.
Rule 1: Wide Area Network (WAN) to LAN (return traffic only):
Traffic arriving from eth0 (the WAN/internet side) heading toward br0 (the LAN bridge) is only accepted if conntrack (ct state) shows the connection was already initiated from the LAN side. This means unsolicited inbound connections from the internet are blocked, exactly what you want from a NAT router/firewall.
Traffic from br0 heading out to eth0 is accepted for new connections as well as existing ones. This lets LAN clients freely initiate connections to the internet.
The trailing counter:
This is a catch-all counter with no action; it just counts packets that matched neither rule above (and will therefore be dropped by the policy). It’s useful for monitoring how much traffic is being rejected.
This is a classic “stateful” firewall pattern. LAN devices can reach the internet freely, but the internet can never initiate connections inward. The related state also allows things like Internet Control Message Protocol (ICMP) errors and File Transfer Protocol (FTP) data channels that are associated with an existing connection to pass through.
When nftables.service loads or reloads the configuration, it flushes the existing ruleset and installs the new one atomically through the netlink interface. No packet sees a partial ruleset during the transition. Reload with:
sudo systemctl reload nftables.service
Validate a configuration file before applying it:
sudo nft -c -f /etc/nftables.conf
If you are gonna dive deep into netfilter, this blog is outstanding
Our third change was defining nf_tables rules for processing packets.
Change 4: Stateful Firewalling with conntrack
The rule fragments ct state { established, related } and ct state { new, established, related } reference conntrack, the kernel’s connection tracking subsystem. Conntrack is what makes two simple rules sufficient to handle all legitimate traffic. The kernel implements the connection tracking core in nf_conntrack_core.c in net/netfilter/.
Conntrack watches traffic as it passes through netfilter and maintains a table of active flows. Each entry stores the source and destination addresses, ports, protocol, and current connection state. When a LAN client opens a Transmission Control Protocol (TCP) connection to a server on the internet, conntrack creates an entry and marks the flow new. Once the three-way handshake completes, conntrack marks it established. Reply packets from the internet match ct state established in the FORWARD chain and pass through automatically.
The firewall allows outbound connections from br0 to eth0 when they carry state new or established. Return packets arriving on eth0 match as established. Conntrack holds the bookkeeping; the firewall rules consult the table.
The related state covers secondary flows. Protocols like FTP open a control connection and then negotiate a separate data connection on a different port. ICMP error messages tie back to existing TCP or User Datagram Protocol (UDP) flows. Conntrack understands these relationships and marks the secondary flows accordingly, so the firewall accepts them without explicit rules for every protocol variant.
Our fourth change is an expansion of network connection tracking in the Kernel’s connection tracking subsystem. We have begun tracking packets for systems beyond just our own host.
Change 5: Defining NAT and Masquerade policies: Rewriting Addresses at the Border
Home networks use Request for Comments (RFC) 1918 private address space: 10.0.0.0/8, 172.16.0.0/12, and 192.168.0.0/16. The public internet carries routes to none of these ranges. Every packet leaving the LAN needs its source address replaced with the router’s public IP before it exits. Without that replacement, the originating host will never receive replies from the internet.
The postrouting chain at the POSTROUTING hook replaces each outbound packet’s private source address with the router’s public address:
The term masquerade relates to the act of disguising oneself. The router pretends to be the original sender of a request bound for the internet, but it remembers which node on the internal network made the original request. The resource on the internet responds to the router as if it’s connecting with the original sender, but the router modifies the packet and sends it on to the original requester. The router presents the LAN client to the outside world under a different identity, the WAN IP, concealing the private address behind a public one. The client appears to the remote server as the router itself. The router hides the client’s original address. The kernel implements the masquerade action in nf_nat_masquerade.c in net/netfilter/.
Conntrack stores the translation as part of each flow’s entry. The tuple (private IP, private port, public IP, public port, protocol) lives in the conntrack table for the lifetime of the connection. You can inspect it directly:
sudo conntrack -L
Each line shows the original and reply tuples for a live flow, along with the connection state and a timeout countdown. Flows that have been idle long enough age out, and conntrack removes their entries, a key mechanism for preventing the NAT table from growing without bound. TCP connections time out after the session closes or after a configurable idle period. UDP entries use shorter timers because UDP carries no close signal.
The masquerade action reads eth0’s current IP address at the moment the packet is processed, rather than at configuration time. This makes it the correct choice for a WAN interface that acquires its address via Dynamic Host Configuration Protocol (DHCP), where the public IP may change without notice. When the address changes, new connections use the new address automatically. Conntrack retains entries for established connections under the old address until they expire.
Our fifth change is defining rules that modify the sender and recipient addresses in packets processed by the host.
Change 6: Vending DHCP and DNS with dnsmasq: Announcing the Router to New Clients
Every computer on the Internet needs to know three things to work: their IP address, their default gateway to the internet, and their Domain Name System (DNS) server.
A router must introduce itself to clients on their network. New clients arrive without an IP address, without a default gateway, and without a DNS resolver. dnsmasq vends these values to clients on their network through DHCP.
When a device joins the network, it broadcasts a DHCP discovery. dnsmasq listens on br0 and responds with an offer containing an IP address, subnet mask, lease duration, and two DHCP options: option 3 (default gateway, 192.168.1.1) and option 6 (DNS server, 192.168.1.1). Option 3 tells the client where to send packets destined for addresses outside the local subnet. Option 6 tells the client which resolver to query. dnsmasq caches upstream responses locally, reducing query volume and accelerating repeat lookups.
dnsmasq binds to br0 so it serves only the LAN. It never listens on eth0.
NetworkManager as an alternative:NetworkManager can handle both DHCP server and DNS functions through its built-in dnsmasq integration, activated by setting dns=dnsmasq in /etc/NetworkManager/NetworkManager.conf. NetworkManager launches its own dnsmasq instance and manages its configuration dynamically as interfaces come and go.
There are significant tradeoffs for each approach. NetworkManager’s approach reduces manual configuration and handles interface lifecycle events automatically. This is useful on a laptop or a machine where interfaces appear and disappear. On a dedicated router, you generally will want greater control. NetworkManager may reconfigure dnsmasq or restart it in response to network events, interrupting DHCP leases in unpredictable ways. A static dnsmasq configuration launched by systemd gives you deterministic startup order, explicit binding, and straightforward log inspection via journalctl -eu dnsmasq.service. You know exactly what the daemon is configured to do because you wrote the configuration file.
From a kernel perspective, both paths land in the same place: a userspace process bound to a UDP socket on port 67, servicing DHCP requests arriving on the bridge interface. The kernel doesn’t distinguish between the two arrangements. The difference is in how the daemon is launched, configured, and supervised. This is a service management and operational tradeoff, not an architectural one.
Our sixth change is deploying a new daemon (dnsmasq) for vending DHCP and DNS services to clients on the system’s network(s).
Change 7: Vending WiFi networks with hostapd: Switching the Wireless Card into Access Point (AP) Mode
Wireless interfaces operate in one of several modes. In managed mode, a card scans for access points and associates as a client. In AP mode, the card broadcasts beacons, accepts association requests, and manages the full authentication lifecycle for connecting devices.
The kernel’s mac80211 subsystem provides a unified programming interface for 802.11 hardware across different driver implementations. hostapd communicates with mac80211 through the nl80211 netlink interface, the same socket-based kernel-userspace channel that nftables uses, applied here to the wireless subsystem. Through nl80211, hostapd commands the driver to enter AP mode, sets the Service Set Identifier (SSID), channel, and Wi-Fi Protected Access 2 (WPA2) encryption parameters, and takes ownership of authentication frames.
The bridge=br0 directive in hostapd.conf attaches the AP interface to the bridge as a member port. Wireless clients, once associated, enter the same Layer 2 segment as wired clients. Their traffic arrives on br0, the kernel applies the same netfilter decisions, and packets travel the same forwarding path as everything else on the LAN.
Debian ships hostapd masked by default. Systemd registers the service but blocks it from starting. This blocking prevents an unconfigured instance from launching and broadcasting an open network. systemctl unmask hostapd removes that block, after which systemctl enable --now hostapd starts it and registers it for future boots.
Our seventh change is deploying a new daemon (hostapd) for vending WiFi networks from the device’s WiFi card.
The Result: A WiFi Router!
Each configuration step activates a different layer of the kernel’s networking architecture. Together, they build a complete forwarding system:
Step
Kernel mechanism
Layer
ip_forward=1 via sysctl
IPv4 stack enables FORWARD path
L3
br0 bridge *
L2
L2 *
nftables FORWARD chain
Netfilter hook, packet policy
L3/L4
conntrack
Stateful connection table
L3/L4
masquerade
Source NAT at POSTROUTING
L3
dnsmasq DHCP
Gateway and DNS announcement
Application
hostapd via nl80211
AP mode through mac80211
L2 wireless
Note on the bridge row: Adding a wired interface to br0 is a direct kernel operation — the bridge module immediately takes over frame forwarding for that port. Adding a wireless interface is indirect: hostapd’s bridge=br0directive handles the attachment after the wireless card enters AP mode and a client associates. Both result in the same logical L2 segment, but the mechanism differs. If you are debugging bridge membership, brctl show(or ip link show master br0) will show wired members directly; wireless clients appear as learned MAC entries in the bridge’s forwarding table once they associate, which you can inspect with brctl showmacs br0.
Start with a Linux machine in its default state: a workstation that receives packets for itself, forwards nothing, and drops traffic addressed to any IP it doesn’t own. Its IP forwarding gate is closed. Its netfilter FORWARD chain is empty. Its wireless card listens for beacons rather than broadcasting them. It has no DHCP server, no NAT table, and no bridge.
IP forwarding opens the gate for the possibility of routing.
The bridge collapses the wired and wireless interfaces into a single addressable domain.
The nftables chains install policy at the FORWARD hook, deciding what passes and what drops.
Conntrack feeds state information into those policy decisions, making simple rules work for complex traffic patterns.
Masquerade hides the LAN behind the router’s public identity and keeps a translation table in memory.
dnsmasq announces the router’s presence and hands every new client the information it needs to reach the outside world.
hostapd converts a client-mode radio into an access point.
These are the changes that transform a Linux system into a WiFi router. You can evaluate and inspect them through 6 commands: