I wanted three things on localhost

My day job is payment integrations, so most of my debugging is about a request I did not make, sent by a server I do not own. Three separate tools used to stand between me and it.

The thing nobody warns you about payment integrations is how little of the interesting part happens in your own code. You send a request, the user vanishes into a bank's 3-D Secure page, and some time later — seconds, or twenty minutes — the provider posts a webhook back to you. That callback is the moment the money becomes real.

Which means the callback has to reach my laptop. Not staging, not a colleague's branch. The machine I am currently changing code on.

And when it goes wrong, it goes wrong inside a request I did not make. Every provider has a dashboard showing what it sent, and every one of them shows a rendered version: the body pretty-printed, the headers abridged, four retries overnight collapsed into one row. None of that settles a failing signature check. For that you need the exact bytes, in the order they arrived, including the header you did not know was there.

That is the itch. Underneath it there is a more general one, and it is the reason any of this exists.

I want two things to be easy, and neither one is.

Letting the internet reach something running on my machine — a webhook, a colleague, a phone, a link I paste into a thread and forget about.

And letting someone I trust use what my machine can do, without handing over the machine. A teammate who needs to see the request that failed. Lately, an agent that needs to read the logs and run one command, and should be able to do exactly that and nothing else.

Both are the same shape: something exists on this laptop, and getting at it from outside is a project every single time. The first half is the one everybody has a tool for. The second half is the one that surprised me, and it is where most of this ended up.

Everything below started as a way to scratch that — and then turned out to be three separate problems wearing a trench coat.

Every project I worked on needed the same three things, and every time I solved them separately.

A public URL. The webhook above has to land on my machine, and someone always has to open a page on their phone. So: ngrok, then cloudflared, then whatever I happened to have installed that month.

Real HTTPS on my own machine. Secure cookies, service workers and WebAuthn do not exist over plain http, so at some point every project hits a wall that only appears locally. mkcert, a certificate, a proxy in front of the app, and a note to myself about which port it ended up on.

A list of what I was actually running. At one point I had six things on localhost — 3000, 8000, 8001, 8002 — plus a pile of Docker containers, half of them stopped. Working out which was which meant lsof -i and guessing.

None of this is hard. All of it is per-project, per-machine setup that I redid every few months, never wrote down, and resented every time.

So I put the three of them in one binary: share a port, serve it over trusted HTTPS on a .local name, and keep a list of what is running. That part is unremarkable, and it is not the interesting bit.

The interesting bit is what fell out of it. Once all of that traffic went through one process, that process could see the traffic — and it turned out that seeing it was worth more than any of the three things I set out to build.

What I actually found in there

The first useful thing was not a bug in my code. It was a request I did not know my app was making, to a service I had forgotten it talked to, on every page load.

The second was an integration failing with a 400 and a body my logs did not record, because the client library helpfully raised an exception and threw the response away.

Neither of those is exotic. Both are invisible until something is watching the wire, and until then you debug them by adding log lines and redeploying.

The problem with watching the wire

Every tool that lets you see HTTP traffic asks you to move something first. A tunnel gives you a new public URL. A desktop proxy asks you to point your system at it. A browser extension only sees the browser.

That first step is where half the real cases fall over. An OAuth callback registered as localhost:3000 in a provider console. A mobile build with the host compiled in. Someone else's script you do not control. You cannot inspect what you cannot redirect.

Port shadow

So the address stays, and the inspection goes underneath it. You point Piperace at the port your clients already use; it takes that port, and your app moves to a neighbouring one.

Everything that was talking to localhost:8000 still talks to localhost:8000. No new hostname, no proxy setting, no certificate to trust, no rebuilt mobile app. The OAuth callback still resolves. The hardcoded script still works. You can finally read what they are sending.

The mechanism is less clever than it sounds, and the honest part is the limitation. Piperace cannot move your app — it has no way to reach into your process and rebind it. So it does the only thing it can: it parks in a "waiting" state, polls the port, and grabs it the moment you restart the app on its new one. The takeover is cooperative by necessity, and the UI says so rather than pretending otherwise.

I have not seen another tool do this, and I suspect the reason is that it only makes sense if you are already the thing serving the port.

Outgoing traffic, and who made the call

Incoming traffic is the half everyone shows. The half that costs you afternoons is outgoing: what your app sent to Stripe, to an LLM provider, to the internal service that returned a 500 with no body.

Desktop proxies do see outgoing traffic. What they cannot tell you is who made the call — and that is architectural, not an oversight. A system proxy sits above every process on the machine, so the browser, the Docker daemon, the system updater and your app all arrive in one undifferentiated stream.

Piperace gives each app its own egress port. A call through that port is provably from that app, labelled with its name, so "what did the worker talk to during that job" is a filter rather than an investigation. There is a shared mode too, which records everything without attribution, for when you just want a net.

Mock it, break it, replay it

Once traffic is visible, the next question is always "what happens if this goes wrong". Piperace answers it without touching your code: install a mock so an endpoint returns whatever you want, inject latency or an error with a probability, then replay a captured request and watch what your app does with it.

Breakpoints work the way they do in a debugger. Arm a filter, and a matching request parks mid-flight until you let it through, edit it, or fail it.

Then I gave it to an agent

This is the part I did not plan. Piperace speaks MCP, so Claude Code and other agents read the captured traffic directly — the real request and the real response, rather than my paraphrase of them.

claude mcp add piperace -- piperace mcp

Other tools in this space have MCP too, and what it gives the agent is a way to open a tunnel. Mine gives it set_mock, inject_fault, replay_request and clear_rules.

That is the difference between an agent reading a dump and an agent running an experiment. It can mock the endpoint, add 2000 ms of latency to a third of the calls, replay the request that failed, and tell you what broke — while you do something else.

The write tools need this instance's session token, read from a user-only file, so a web page you happen to have open cannot drive them. Secrets in headers and bodies are redacted before anything reaches the agent. All of it runs against traffic that never left the machine.

Letting an agent run commands, without handing it a shell

An agent debugging a server eventually needs to run something: docker logs, tail, systemctl status. The usual answers are to give it SSH, or to wire up a "run this command string" tool, which is the same thing with extra steps.

Instead you declare the exact commands that may run, as programs with typed parameters. There is no shell anywhere in the path, so a parameter cannot turn into another command — the entire class is gone by construction rather than by escaping.

The obvious objection is that you cannot predict what the agent will need, and it is correct: an allowlist asks you to decide before the session what only becomes clear during it. So anything not on the list is not refused. The agent asks, the request appears on your screen with the exact command and its stated reason, and one click runs it — returning the output on the call the agent is still holding. No restarted conversation, no editing config mid-debug.

The tunnel

There is a tunnel, and it is one toggle. It dials out, so nothing on your machine starts listening on the network, and you get a real HTTPS URL for a webhook, a demo, or a phone that is not on your Wi-Fi.

piperace http 3000 -d myapp     # → https://myapp.piperace.fun

It is deliberately the least interesting part of this post, which is a strange thing to say about the feature I originally set out to build. Everything above happens with the tunnel switched off.

What leaves your machine

Captured requests, bodies, mock rules, logs and command output stay local. They live in memory and on your disk. No account is required to use any of it, and nothing is sent to us for inspection.

Two things do reach the network, and only these. A tunnel, when you turn one on — that is the entire point of it, and your traffic crosses our servers while it is up. And a version check, so the app can tell you an update exists.

The version check sends nothing about you: no request contents, no hostnames, no project names, no identifiers. It asks for a number. PIPERACE_SELF_UPDATE=0 stops it entirely and changes nothing else.

The local certificate authority is worth its own sentence, because it is the one thing that touches your system trust store. The CA is generated on your machine, its private key never leaves it, and it exists solely to sign the .local names you asked for. Installing it is an explicit action, and the same screen removes it.

"Isn't this just mitmproxy and ngrok?"

Piperace gets filed under "ngrok alternative", and that framing hides most of it. The honest map is a grid: traffic is either incoming or outgoing, and it either stays on your machine or leaves it.

Incoming Outgoing
Stays local Local HTTPS, port shadow
mkcert, Caddy, Localias
Per-app proxy, with attribution
Charles, Proxyman, mitmproxy
Leaves the machine Public tunnel
ngrok, Cloudflare Tunnel, Pinggy, zrok
Shared proxy, everything recorded
system proxies

Each of those is good at its own cell. mkcert issues certificates and stops there. ngrok is an excellent tunnel and does not mock anything. Charles and Proxyman are fine debuggers and, being system proxies, cannot tell you which process made a call. WireMock and Mockoon serve mocks but never see your real traffic. Requestly is closest in spirit and lives in the browser, so server-side outgoing calls are invisible to it.

The claim is not that Piperace beats any of them inside their own cell. It is that the grid is one workflow, and splitting it across four tools is why most people do three of the four badly and skip the fourth.

What it costs, and what I am still getting wrong

The economics are simple enough to state plainly. Everything that runs on your machine costs me nothing to provide: local HTTPS, port shadow, inspection, mocks, faults, breakpoints, the MCP server, the Control Center. Tunnels cost real bandwidth, because your traffic crosses my servers.

That is where the line belongs, and today the build does not draw it there. The free tier currently limits apps — one anonymously, two signed in — which meters the local half, the half that costs nothing. That is backwards, I know it is backwards, and it is the next thing I am changing.

What I am not going to do is the move that burned this category before: ship something as unlimited during a beta and reprice it once people have built it into their day. If a limit changes it will change for new installs, and it will be a number you can read before you commit.

Try it

npm install -g @piperace/cli
piperace ui

No account needed. Point it at a port you already use and look at what comes through. If it shows you something you did not know your app was doing, that is the whole pitch.

← Back to blog