Grow Your “Agentic Prompt” One Section at a Time

This series covers prompt patterns for agents that run in a loop: the agent builds something, measures it, improves it, and repeats the cycle the next day without a person supervising. Across this series I cover:

  • How to grow a long prompt without it contradicting itself
  • How to keep an agent from damaging the machine it runs on
  • How to hand an agent a loop it can run without approvals
  • How to let real usage tune an interface
  • How to get creative output you don’t have to babysit
  • How to make the loop improve its own prompts

These patterns are for builders whose agent work looks like mine. I write prompts for Claude Code and hand them to an agent that works on its own for hours. My agents run across many sessions, so the prompt has to carry everything the next session needs to pick up the work. I patch my prompts constantly, and before I used these patterns each patch made the prompt longer and less coherent. These patterns pay off on the second and later cycles of a loop, so one-shot prompts get little from these patterns.

My daughter is old enough to have a phone so she can keep up with her friends. I’ve come to the conclusion that notifications, infinite scroll, and algorithms tuned for engagement are producing adults with limited reasoning and focus. So I gave her a Light Phone. She can text, take photos, and listen to music, and she skips the cognitive denial of service attack that comes with a modern smartphone.

She also needs to start learning how to make things with AI, so I built her an Omarchy workstation. Then I realized a workstation for learning AI was a bit like putting her on a vegan diet. Veggies are nice and all, but learning needs some fun in it. Quake had an outsized influence on my origin story. Quake is the reason I learned TCP/IP networking, and Quake pulled me into hacking and security. Maybe Quake would do the same for her.

So I set out to build a Quake server she could play on with her friends. The project vends a game server, multiplayer clients that run on Mac, Linux, or a mobile browser, and a nightly SportsCenter-style recap of the night’s games, complete with breakfast awards we review the next morning. I wanted the server to make it worth practicing a few Linux commands every day. I wanted an agent to build, run, and keep improving the whole system, on a home server that already runs a bunch of services my family relies on.

Each pattern in this series is a small trick, usually a paragraph or two of prompt text. The value comes from how the patterns stack on each other. I’ve found these patterns produce productivity acceleration similar to compound interest. A savings account that earns interest grows slowly at first and then quickly. Each year’s gain becomes part of the base for the next year. These patterns work the same way for agentic development. A build plan that absorbs every successful experiment makes the next rebuild better. A log of every confusing instruction makes next week’s prompts clearer. Each cycle of the agent’s loop starts from a higher base than the cycle before.

This first post covers how to grow the prompt itself. I couldn’t write my agent’s prompt in one pass, and rewriting the prompt every time a gap showed up kept breaking rules I’d already gotten right. My build loop went through eight rounds. Each round surfaced a requirement I hadn’t planned for, like sudo commands on a shared box, iPads, and an LLM that talks to the kids during play. Each new requirement became a new section added to the prompt instead of a rewrite.

I found these practices produced large amounts of time savings while the prompt grew:

  1. Wire every new section into the plan contents list, the acceptance criteria, and the directory layout. The agent decides what to build from the plan contents list, what to test from the acceptance criteria, and where to put files from the directory layout. A requirement missing from the plan contents list, the acceptance criteria, and the directory layout gets skipped. Inserting new sections into the plan’s shared planning section ensures each new requirement built, tested, and stored in accordance with standards for the whole project.
  2. Rank your goals in a numbered priority list at the top of the prompt. When two goals conflict, the agent follows the higher-ranked goal instead of guessing. Every rule section must cite a priority number, so each goal keeps a single wording as the prompt grows.
  3. Write one interpretation of each ambiguous requirement into the prompt, and tell the person who gave the requirement which interpretation you chose before the agent runs. The person can correct a wrong interpretation with one message, before the agent spends a week acting on the wrong interpretation.

My build objectives

I wrote the priorities first, in rank order, so the agent knows which goal to give up when two goals collide.

1. Protect existing services. Every service on this host keeps running exactly as it runs today. A Quake feature that risks an existing service gets dropped or redesigned.
2. Working LAN Quake. Quake clients on the LAN connect and play fun sessions.
3. Game modes. Deathmatch and Quake Team Fortress work.
4. Web app. A local web app serves players and the operator.
5. Creativity. The experience produces stories the family retells later.

Every rule section in my prompt opens by naming the priority the rule section protects. The standing rules section starts with “Each rule exists to keep priority 1 intact while you work.” When I added a privilege model section three rounds later, the privilege model section cited priority 1 instead of explaining again why the other services matter.

Problem: an agent hits conflicts between goals all the time, and a prompt that states its goals in several places gives the agent several different answers about which goal wins. Say the agent finds that QuakeWorld’s default port, 27500, is already taken by another service on the box. The networking section says players must be able to connect. The safety section says existing services keep running. If each section describes its own goal in its own words, with no ranking between the goals, the agent has no tiebreaker, and the agent could free the port by catastrophically stopping the other service. The wording of goals also drifts apart as the prompt grows, because each new section restates the goals a little differently, and after a few rounds two sections disagree about which goal wins.

Solution: I wrote the goals once, ranked, at the top of the prompt. Each rule section opens by naming the priority number the rule section serves. When two rules conflict, the agent compares the priority numbers of the two rules and follows the rule with the lower number. In the port example, priority 1 protects existing services and priority 2 is working Quake, so the agent picks a different free port and leaves the other service running. Later sections cite a priority number instead of restating the goal, so each goal has exactly one wording in the whole prompt and the sections stay in agreement as the prompt grows.

The pattern that carries over to any prompt: rank your goals once, in one place, and make every rule point to a rank. The ranking gives the agent a tiebreaker the orchestration agent can apply without asking you, and the single wording of each goal keeps new sections from redefining old goals.

Adding a section for each problem

When a new requirement showed up, I added a new headed section to the prompt for that requirement and left the existing sections as they were. My prompt is one markdown file with a heading per topic: priorities, standing rules, and one heading per build phase. Each requirement the file didn’t cover got its own heading. The new requirements showed up in this order:

RoundProblem that came upSection I added
2Some steps need sudo and could hurt other servicesPrivilege Model and Change Control
3Claude Code on the host can generate content during playPhase 4B: LLM-Powered Live Experiences
4Players on iPads and iPhones need a way inResearch topic 10, iPad and iPhone play
5Touch controls will need dozens of small tweaksPhase 5B: Apple Device UI Fast Loop
6I want a daily page that recaps the last sessionPhase 4C: The Chronicler Agent
7I want cheaper models doing most of the workOrchestration and Model Tiers

Problem: rewriting the prompt to absorb the iPad requirement would have meant touching the server, client, and testing sections at once. This would result in the loss of prompt instructions that I’d worked hard to tune and that produced high quality results.

Solution: I wrote the iPad requirement as its own research topic and left the server, client, and testing sections untouched. A new section can only refer to things the prompt already names, so each new section forced me to name the things the new section needed to point at. The privilege model needed to tell “commands that touch only my project” apart from “commands that touch the whole server,” so I defined privilege tiers. The feature toggles needed a list of features, so I created a feature manifest file. Resuming after an interruption needed a place to read progress from, so I created the STATE/ directory. Every later section points at the privilege tiers, the feature manifest, and the STATE/ directory by name instead of describing them again.

The pattern that carries over to any prompt: when a new section needs to refer to something that only exists as scattered descriptions, give that thing a name and a file. Named things become stable reference points that later sections can cite without redefining them.

Wiring each new section into the build, test, and file lists

After writing a new section, I edit three other places in the prompt: the plan contents list, the acceptance criteria, and the directory layout. The agent reads the plan contents list to decide what to build, the acceptance criteria to decide what to test, and the directory layout to decide where files go.

flowchart LR
    P[Unanticipated problem] --> S[New prompt section]
    S --> A1[Build plan contents]
    S --> A2[Acceptance criteria]
    S --> A3[Directory layout]
    A1 --> B[Agent decides what to build]
    A2 --> T[Agent decides what to test]
    A3 --> F[Agent decides where files go]

Here’s the wiring for iPad support. The iPad research section was the new section. The edits below to the plan contents list, the acceptance criteria, and the directory layout made the agent build, test, and store iPad support.

Plan contents:

- Client bundle contents and install flow for Linux, plus the chosen iPad and iPhone path: browser client hosting and transport bridge, or native app build and install procedure, including any local TLS setup and the per-device onboarding steps.

Acceptance criteria:

- An iPad and an iPhone each complete the documented onboarding and join the same match as a Linux client, with playable frame rate and working touch or controller input.

Directory layout:

  ios-ui/   # Apple device UI configs, envelope, tests, telemetry, fast-loop log

The agent builds and tests iPad support only when the plan contents list and the acceptance criteria both list iPads. The agent builds the items in the plan contents list and tests the items in the acceptance criteria. A prompt with the iPad research section alone would get an agent that researches iPad options, writes a recommendation, and then builds and tests a Linux-only server.

Problem: a requirement described only in its own section stays out of the lists the agent builds and tests from, so the agent researches the requirement and never ships the requirement.

Solution: after adding a section, add a line for the new requirement to the plan contents list, the acceptance criteria, and the directory layout. I use the plan contents edit, the acceptance criteria edit, and the directory layout edit as a checklist every time the prompt grows.

Naming the files each phase produces

Every phase in my prompt ends with an Artifact line listing the files the phase must produce. The agent marks the discovery phase complete when these files exist:

Artifact: STATE/discovery.md, STATE/storage-decision.md, STATE/blog-howto.md,
STATE/healthcheck.sh, STATE/baseline-health.md

The listed files are the documentation for the phase, so the record of the phase is the work the agent already produced. The same files are also where a fresh agent session picks up the work after an interruption.

Stating which interpretation of an ambiguous requirement got encoded

When a requirement could mean two things, the prompt author writes one interpretation into the prompt and tells the person who gave the requirement which interpretation was chosen. I asked for a daily recap page that becomes “viewable the day after the last visit.” The phrase could mean the day after anyone’s visit or the day after a specific kid’s visit. Claude, drafting the prompt with me, chose the day after anyone’s visit, wrote that interpretation into the publication rules, and told me the choice in the same reply. Correcting the interpretation at that point would have taken one message from me. Correcting the interpretation after a week of the agent publishing on the wrong schedule would have meant reading logs to figure out why the page appeared on the wrong days.

What I’d do differently

I worked these practices out while the prompt was already growing. Starting the next project with these improvements in place from the first draft would make each round of growth cheaper.

I’d put empty headings for the plan contents list, the acceptance criteria, and the directory layout into the very first draft.
Why: without the headings in place, wiring each new requirement into the build, test, and file lists depends on my remembering to do it. A section that misses the wiring describes work the agent researches and never builds.
How: start the first draft with a “Plan contents” heading, an “Acceptance criteria” heading, and a “Directory layout” heading, each empty. Every time I add a section, I add one line under each heading in the same edit, so each new requirement reaches the build, the tests, and the file layout in the round I write the requirement.

I’d write the priority list before anything else and add only rules that name the priority they serve.
Why: a rule with no priority behind it gives the agent instructions without a reason, so the agent can’t tell how much the rule matters when the rule conflicts with another rule. Unranked rules also make the prompt longer without making it clearer.
How: write the ranked goals as the first section of the prompt. Before adding a rule, write the priority number the rule protects as the rule section’s first sentence. If no priority fits, either the rule is unnecessary or the priority list is missing a goal, and I fix the priority list first.

I’d keep a dated log of every interpretation chosen for an ambiguous requirement, in the prompt repo.
Why: interpretations get decided in conversation and then disappear from memory. When the agent does something odd months later, I need to know whether the agent is following an interpretation I approved and forgot, or whether the agent is making a mistake.
How: create an INTERPRETATIONS.md file next to the prompt. Each entry records the date, the original requirement wording, the interpretation chosen, the alternative that was rejected, and the prompt section that encodes the interpretation. Check that file first whenever the agent’s behavior surprises me.

Summary

A prompt for a long-running agent grows one requirement at a time, and these practices kept my prompt coherent through eight rounds of growth:

  • Rank your goals in a numbered priority list at the top of the prompt, and open every rule section with the priority number the rule serves. The agent uses the ranking as a tiebreaker when goals conflict.
  • Add a new headed section for each new requirement and leave the existing, tuned sections as they are. Give a name and a file to anything a new section needs to point at.
  • Wire each new section into the plan contents list, the acceptance criteria, and the directory layout, so the agent builds, tests, and stores the new requirement.
  • End every phase with an Artifact line naming the files the phase must produce. Those files serve as the phase’s documentation and as the restart point after an interruption.
  • Write one interpretation of each ambiguous requirement into the prompt and confirm the interpretation with the person who gave the requirement before the agent runs.

The next post will cover guardrails for keeping an agent from damaging the machine it runs on.



Working with AI, one folder per project: every session creates compounding value

Many students now study with AI chat apps such as Claude or ChatGPT. A common approach works like this: you open the app, upload your study guide, and ask the AI to write practice questions from it. The AI reads the guide and quizzes you on your own material. For a single study session, this works well.

The approach feels good in the short term- but it fails to support you in the long term. Three weeks after the midterm, the final arrives. Your earlier conversations are buried in the app’s history, the study guide needs uploading again. You retype a request you only half remember. Each chat answer gets used once.

Keeping your prompt files on your computer is a more sustainable way to work. Each project gets a folder holding your source material, the requests you wrote, and the documents the AI produced. Save copies of your prompts in the prompt folder. Invoke the coding agent to read the prompt file directly. Every new session then starts with all the information and solutions you already built. Your results improve each time you enhance the files.

Create one project folder with resources, prompts, and outputs so the AI works from your files and saves its work beside them

This takes about ten minutes per project.

  1. In your Documents directory, create a folder named for the project, such as “bio-final.” Inside the project folder, create three subfolders: resources, prompts, outputs.
  2. Copy your source material into resources: a study guide, a research document, a CSV of transactions.
  3. Write your request as a text file and save it in prompts, such as “practice-exam.md.”
  4. Open a coding agent in the project folder. A coding agent, such as Claude Code, is an AI program that reads and writes files on your computer at your direction. Tell the agent to read your prompt, process the files in resources, and write its results to outputs.

Debate the draft, then fix the prompt that produced it

  • When the agent produces a document: read the document and argue with the agent about it. Push back until the document matches what you want.
  • When you settle on a version: ask the agent to compare your original prompt with the result you ended up with, and to rewrite the prompt so it produces that result on the first try. Save the new prompt.
  • When you start any new AI task: create its folder first.

Each cycle results in a sharper prompt. Each run gets you closer to the output you want. Over a semester, your Documents directory becomes a record of every AI project you have run, each one reusable.

Build the folder first and the answers will keep paying you back.

Automation: Taxes and expense tracking for your utility bills:

Ten minutes now saves you a weekend in April

Picture next April. You sit down to do your taxes, and the form asks what you spent last year on electricity, trash, water, and gas. Four companies sent you roughly twelve bills each, and all 48 sit scattered through a year of email.

Digging them out by hand costs an afternoon, and a missed bill puts a wrong number on your return. Today your bills land in your inbox with everything else. After the work below, each bill files itself into a single tax folder, and year-end reporting means opening that folder and adding up four totals.

Build four email rules so every utility bill files itself into one tax folder

This takes about ten minutes, once.

  1. Find one recent bill email from each company: electricity, trash, water, gas.
  2. Create a label (Gmail) or folder (Outlook) named “Taxes 2026.”
  3. Build one rule per sender:
    • Gmail: open the bill, click the three dots, choose “Filter messages like these,” then “Create filter,” and check “Apply the label.”
    • Outlook: right-click the bill, choose “Rules,” then “Create rule,” and set it to move messages from that sender into your folder.
  4. Apply each rule to existing mail so this year’s earlier bills move over too.

Check the folder when a bill arrives, total it once a year

  • When a bill arrives: glance at the tax folder and confirm the bill landed there. Five seconds.
  • Every January 1: create the new year’s folder and point the four rules at it. Five minutes.
  • At tax time: open the folder, total each utility, and enter the four numbers.

I learned this one the slow way, searching my inbox bill by bill.

Some additional gmail automation notes:

Gmail calls tags “labels,” and filters apply them automatically.

Create a filter that applies a label

  1. In Gmail on the web, click the slider icon at the right edge of the search bar (Show search options).
  2. Fill in the properties to match: From, To, Subject, Has the words, Doesn’t have, Size, Has attachment.
  3. Click Create filter (bottom of that panel).
  4. Check Apply the label, then pick an existing label or choose New label.
  5. Optional: check Also apply filter to matching conversations to label existing mail too.
  6. Click Create filter.

Shortcut from an existing email: open the message, click the three-dot menu, choose Filter messages like these. Gmail pre-fills the sender.

Matching more precise properties

The “Has the words” field accepts any Gmail search operator, so you can match on far more than the form fields offer:

{term1 term2} is shorthand for OR, and a space between terms means AND

from:(alice@example.com OR bob@example.com) matches either sender

list:announce@example.com matches a mailing list

filename:pdf or filename:invoice matches attachment type or name

subject:(invoice OR receipt) matches either subject word

to:me+shopping@gmail.com matches a plus-address you gave out

larger:5M matches by size

-from:boss@example.com excludes a sender

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



edit:

I hope in some small way this post influenced this outcome.

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 awareness 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 vuln discovery 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 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.

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)
  • tcpdump, iproute2 (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 (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.

[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.