<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Kunal</title>
    <description>The latest articles on DEV Community by Kunal (@kunalgoesbyken).</description>
    <link>https://dev.to/kunalgoesbyken</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F978538%2Fbfa7b8a4-b3fa-4f2f-8850-a7a041acfbde.jpg</url>
      <title>DEV Community: Kunal</title>
      <link>https://dev.to/kunalgoesbyken</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kunalgoesbyken"/>
    <language>en</language>
    <item>
      <title>Three copies of the same HTTP code, and how one Go engine fixed it</title>
      <dc:creator>Kunal</dc:creator>
      <pubDate>Thu, 12 Nov 2026 02:36:17 +0000</pubDate>
      <link>https://dev.to/kunalgoesbyken/three-copies-of-the-same-http-code-and-how-one-go-engine-fixed-it-i61</link>
      <guid>https://dev.to/kunalgoesbyken/three-copies-of-the-same-http-code-and-how-one-go-engine-fixed-it-i61</guid>
      <description>&lt;p&gt;&lt;em&gt;A free API client for REST, GraphQL, gRPC and WebSocket, and what building it taught me about SSRF, sandboxing other people's JavaScript, and keeping secrets away from AI agents.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It started with a login screen.&lt;/p&gt;

&lt;p&gt;I wanted to send one request to my own API, running on localhost, on my own machine. The API client I'd used for years asked me to sign in first. Then it wanted to sync my workspace. Then a banner told me that running a collection against a CSV file was a paid feature.&lt;/p&gt;

&lt;p&gt;I wasn't even angry, I was curious. How hard can an API client be?&lt;/p&gt;

&lt;p&gt;That was April 2025. The answer turned out to be "a lot harder than it looks", and also way more fun than I expected. The weekend project became Gostman, a free and open source (MIT) API client for REST, GraphQL, gRPC and WebSocket. It runs as a desktop app, in the browser and as a CLI, and there's no account, no telemetry in the app and no paid tier.&lt;/p&gt;

&lt;p&gt;This isn't a feature tour. It's the engineering story: one decision I got wrong, the rewrite that fixed it, and three problems that looked small and really weren't.&lt;/p&gt;

&lt;p&gt;If you only read one paragraph, read this one. Every request, from every surface (desktop, browser, CI, an AI agent), goes through one Go function. A small &lt;code&gt;Policy&lt;/code&gt; value carries what's different about each environment. The public web proxy checks the IP it actually connects to, which closes the DNS rebinding hole that URL checks leave open. Scripts from imported collections run in the CLI inside a pure-Go JavaScript engine with time and memory limits. And secrets get matched by their value, including their common encodings, and replaced before anything reaches an AI agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  So yeah, why Go?
&lt;/h2&gt;

&lt;p&gt;An API client is mostly networking. Timeouts, redirects, TLS, proxies, cookies, cancellation, streaming. Go's standard library already has all of it, &lt;code&gt;net/http&lt;/code&gt;, &lt;code&gt;crypto/tls&lt;/code&gt;, &lt;code&gt;context&lt;/code&gt;, a cookie jar, and none of it is magic.&lt;/p&gt;

&lt;p&gt;I also wanted &lt;code&gt;gostman run&lt;/code&gt; to work in any CI pipeline without telling people to install Node.js first. Go gives you one static binary. Build it, copy it, done.&lt;/p&gt;

&lt;p&gt;For the desktop app I picked &lt;a href="https://wails.io" rel="noopener noreferrer"&gt;Wails&lt;/a&gt;. The backend is Go, the UI is React, and you don't ship a whole Chromium like Electron does. What I didn't appreciate at the time is that this meant the same Go code could sit behind a native window &lt;em&gt;and&lt;/em&gt; behind a serverless function. That ended up being the entire story.&lt;/p&gt;

&lt;h2&gt;
  
  
  THE ARCHITECTURE:
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk5k4cwb4divbwgzxyb87.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk5k4cwb4divbwgzxyb87.png" alt="One request engine, four front ends" width="800" height="479"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Four ways in, one way out. The desktop app, the browser, a CI job and a coding agent all end up in the same function. It definitely didn't start like this though.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 1: A desktop app that sends requests
&lt;/h2&gt;

&lt;p&gt;Version one was pretty much what you'd guess. A Wails app with React on top. The UI builds a request object and hands it to Go, Go makes the HTTP call and sends back status, headers, body and timing.&lt;/p&gt;

&lt;p&gt;Everything was saved locally on your machine, no database and nothing in the cloud. (Much later, collections became plain YAML files in a folder, so you can commit them next to your code.)&lt;/p&gt;

&lt;p&gt;It worked, it was fast, and I was happy with it. Then people started asking for a version they could try without installing anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 2: The browser version, and my biggest mistake
&lt;/h2&gt;

&lt;p&gt;Which is fair. I wouldn't install a random app from a stranger on Reddit either.&lt;/p&gt;

&lt;p&gt;So I built a web version. A browser can't call any API you type in because of CORS, so requests from the web app go through a small Go proxy function on Vercel.&lt;/p&gt;

&lt;p&gt;This is where I messed up. The web frontend got its own JavaScript copy of the request preparation: variable substitution, query params, GraphQL bodies, auth headers, curl parsing. And the Go code that actually sent the request now existed twice too, once in the desktop backend and once in the Vercel proxy. Three copies of "basically the same code".&lt;/p&gt;

&lt;p&gt;It was not basically the same code.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwffacuh0khllsps284nu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwffacuh0khllsps284nu.png" alt="Before and after: three copies of the request logic vs one engine" width="799" height="416"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Copies of the same logic don't stay the same for long. Mine drifted slowly and quietly, and then all at once.&lt;/p&gt;

&lt;p&gt;Binary responses (images, PDFs, anything that isn't text) came back corrupted, because both Go copies did &lt;code&gt;string(bodyBytes)&lt;/code&gt; on the response and handed the UI mangled text.&lt;/p&gt;

&lt;p&gt;Big integers changed value on the way out. Variables and GraphQL variables were decoded into floats, &lt;code&gt;JSON.parse&lt;/code&gt; on the JavaScript side and &lt;code&gt;json.Unmarshal&lt;/code&gt; into &lt;code&gt;map[string]any&lt;/code&gt; on the Go side, and a 64-bit float can't hold every integer above 2^53. So a database ID like &lt;code&gt;9007199254740993&lt;/code&gt; got sent as &lt;code&gt;9007199254740992&lt;/code&gt;. A &lt;em&gt;different&lt;/em&gt; ID, with no error anywhere. That one was really fun to track down.&lt;/p&gt;

&lt;p&gt;And cookies set during a redirect just vanished. Neither copy had a cookie jar, so a &lt;code&gt;Set-Cookie&lt;/code&gt; on the first hop was never sent to the second.&lt;/p&gt;

&lt;p&gt;Every bug I fixed in one copy was still sitting in another one. I was maintaining three API clients and telling myself it was one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 3: One engine
&lt;/h2&gt;

&lt;p&gt;The fix was boring on purpose. There is now exactly one piece of code that builds and sends a request, a Go package called &lt;code&gt;engine/&lt;/code&gt;, and from the outside it's one function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nc"&gt;Send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nc"&gt;Policy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The desktop app calls it through a Wails binding. The Vercel proxy now just calls it too, and the JavaScript copy is gone. When the CLI and the MCP server came along, they called it as well. Nothing else builds its own &lt;code&gt;http.Request&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;All the hard stuff lives behind that function: variable substitution, a dozen auth schemes (Basic, Bearer, OAuth 2 with PKCE, AWS SigV4, Digest, NTLM, Hawk and more), body encoding, redirects, the cookie jar, proxies and client certificates.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Policy struct
&lt;/h3&gt;

&lt;p&gt;The obvious question: if the desktop app and a public web proxy run the same code, how does the desktop app still reach &lt;code&gt;localhost:3000&lt;/code&gt; while the public proxy refuses to?&lt;/p&gt;

&lt;p&gt;Everything that's different between environments goes into one value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="nc"&gt;Policy&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;BlockPrivate&lt;/span&gt;      &lt;span class="kt"&gt;bool&lt;/span&gt;
    &lt;span class="nc"&gt;MaxResponseBytes&lt;/span&gt;  &lt;span class="kt"&gt;int64&lt;/span&gt;
    &lt;span class="nc"&gt;MaxTimeout&lt;/span&gt;        &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Duration&lt;/span&gt;
    &lt;span class="nc"&gt;Permit&lt;/span&gt;            &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
    &lt;span class="nc"&gt;RevealSecrets&lt;/span&gt;     &lt;span class="kt"&gt;bool&lt;/span&gt;
    &lt;span class="nc"&gt;MaxStreamDuration&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Duration&lt;/span&gt;
    &lt;span class="nc"&gt;RunCommands&lt;/span&gt;       &lt;span class="kt"&gt;bool&lt;/span&gt;
    &lt;span class="nc"&gt;RawSockets&lt;/span&gt;        &lt;span class="kt"&gt;bool&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The desktop app passes a relaxed one. The hosted proxy passes this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nc"&gt;Policy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Policy&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;BlockPrivate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;RecordBody&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;MaxResponseBytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;MaxTimeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;MaxStreamDuration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;MinStreamRetry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The engine never asks "am I running on a server right now?". It asks the policy. Supporting a new place to run means writing a new &lt;code&gt;Policy&lt;/code&gt;, not adding &lt;code&gt;if isWeb&lt;/code&gt; checks all over a package of nearly 9,000 lines.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why it works
&lt;/h3&gt;

&lt;p&gt;The big one for me: if a request passes in the desktop app, &lt;code&gt;gostman run&lt;/code&gt; in CI builds and sends it the same way. Only the limits differ, and those are explicit in the policy. "Works in the GUI, fails in the pipeline" just stopped happening. Bugs get fixed once too. The binary body bug became "keep bytes as bytes and base64 them for the UI". The big number bug became &lt;code&gt;decoder.UseNumber()&lt;/code&gt;, which keeps the digits as text. And new front ends got cheap. The CLI and the MCP server were mostly wiring, because the hard part was already there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 4: Putting a proxy on the internet (the SSRF problem)
&lt;/h2&gt;

&lt;p&gt;Here's the scary part. The web proxy fetches &lt;em&gt;whatever URL a stranger sends it&lt;/em&gt;. Without care, anyone can ask my server to fetch &lt;code&gt;http://169.254.169.254/&lt;/code&gt; (the cloud metadata endpoint) or something on an internal network. That's SSRF, server-side request forgery.&lt;/p&gt;

&lt;p&gt;The obvious fix is to parse the URL, resolve the hostname, check the IP is public, and then send the request. It looks right. It isn't.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Famgh9vtmc3ox3wvcu3mz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Famgh9vtmc3ox3wvcu3mz.png" alt="Naive check vs checking inside the dialer" width="800" height="487"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;DNS doesn't have to give you the same answer twice. A hostname can resolve to a perfectly normal public IP while you check it, and to &lt;code&gt;127.0.0.1&lt;/code&gt; a few milliseconds later when the HTTP client actually connects. That's DNS rebinding, and the check-then-connect approach walks right into it.&lt;/p&gt;

&lt;p&gt;So the check moved into the dialer. The IP you check is the IP you connect to, with no second lookup in between (slightly simplified):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;guardedDial&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;network&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;family&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;net&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;net&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SplitHostPort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;address&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;addresses&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;net&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DefaultResolver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;LookupIPAddr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;candidate&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;addresses&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isBlockedIP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;IP&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;errBlockedAddress&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;candidate&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;addresses&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;dialer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DialContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;network&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;net&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;JoinHostPort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;IP&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details turned out to matter a lot more than I expected.&lt;/p&gt;

&lt;p&gt;First, refuse the host if &lt;em&gt;any&lt;/em&gt; of its addresses is blocked, not just the first one. Otherwise a record with one public and one private address gets through the moment the client falls back to the second one.&lt;/p&gt;

&lt;p&gt;Second, the standard library checks are a good start but not enough. An address is blocked if it's not &lt;code&gt;IsGlobalUnicast()&lt;/code&gt; (that catches loopback and link-local, including the &lt;code&gt;169.254.169.254&lt;/code&gt; metadata endpoint) or if it's &lt;code&gt;IsPrivate()&lt;/code&gt;. That still misses carrier-grade NAT (&lt;code&gt;100.64.0.0/10&lt;/code&gt;), the benchmarking range (&lt;code&gt;198.18.0.0/15&lt;/code&gt;), NAT64 (&lt;code&gt;64:ff9b::/96&lt;/code&gt;), 6to4 (&lt;code&gt;2002::/16&lt;/code&gt;), IPv4-translated IPv6 (&lt;code&gt;::ffff:0:0:0/96&lt;/code&gt;) and a handful of reserved ranges. Several of those are ways to reach an internal IPv4 address through something that doesn't look like one. So there are 22 extra ranges on top. (Plain IPv4-mapped addresses like &lt;code&gt;::ffff:127.0.0.1&lt;/code&gt; are already handled, because Go normalises them to 4 bytes before those checks run.)&lt;/p&gt;

&lt;p&gt;This guard is a policy switch, not a law of nature. It's on in the hosted proxy, and off by default on the desktop, where reaching localhost is the whole point. Because it lives in the engine's dialer, it covers every request the proxy makes: HTTP, GraphQL, SSE, gRPC, calls to MCP servers, and every redirect hop too. (WebSockets in the web app connect straight from your browser, so there's no server in the middle to protect.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 5: The CLI, and running a stranger's JavaScript
&lt;/h2&gt;

&lt;p&gt;Once the engine existed, &lt;code&gt;gostman run&lt;/code&gt; was the obvious next step. It runs a whole collection in CI, writes JUnit or HTML reports, and exits non-zero when a check fails.&lt;/p&gt;

&lt;p&gt;People coming from Postman expect pre-request and post-response scripts with &lt;code&gt;test()&lt;/code&gt; and &lt;code&gt;expect()&lt;/code&gt;. In the apps, those run in a Web Worker. In the CLI I really didn't want "install Node.js" to be a setup step, so scripts run in &lt;a href="https://github.com/dop251/goja" rel="noopener noreferrer"&gt;goja&lt;/a&gt;, a JavaScript engine written in pure Go. The CLI embeds (&lt;code&gt;//go:embed&lt;/code&gt;) a bundle built from the same JavaScript modules the apps run, so the &lt;code&gt;test()&lt;/code&gt;/&lt;code&gt;expect()&lt;/code&gt; helpers exist once. Same lesson as Phase 2: one copy, so nothing can drift.&lt;/p&gt;

&lt;p&gt;The catch is that scripts come from imported collections, which means from strangers. And strangers write things like&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or my personal favourite&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Neither one is allowed to hang your CI job or eat your laptop's memory.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnjtyfvvz8mdkax6wyvxa.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnjtyfvvz8mdkax6wyvxa.png" alt="The script watchdog" width="800" height="579"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;goja can stop a running script with &lt;code&gt;vm.Interrupt()&lt;/code&gt;, so every script runs under a watchdog with two limits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vm&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;goja&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Runtime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;watchdog&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;guard&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;watchdog&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;vm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;vm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ticker&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NewTicker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;memoryInterval&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;chan&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;guard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AfterFunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;guard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;interrupt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;errExpired&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ceiling&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nf"&gt;heapInUse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;memoryAllowance&lt;/span&gt;
    &lt;span class="k"&gt;go&lt;/span&gt; &lt;span class="n"&gt;guard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;audit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ceiling&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;guard&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Time is the easy one, &lt;code&gt;time.AfterFunc&lt;/code&gt; interrupts the VM when it runs out. Memory is a compromise. goja can't tell you how much one runtime has allocated, so a goroutine reads the process heap from &lt;code&gt;runtime/metrics&lt;/code&gt; every 50 ms and interrupts the script if the heap is more than 512 MB above where it was when the script started.&lt;/p&gt;

&lt;p&gt;To be honest about it: this is a tripwire, not an isolation boundary. It measures the whole process, so a parallel run's allocations count too, and one huge allocation can land between two samples. A subprocess with OS limits would be stricter, but it would break the single-binary story. For runaway loops like &lt;code&gt;a.push(a)&lt;/code&gt; it does exactly what I need. It turns "the CI runner got OOM-killed and nobody knows why" into "script exceeded its memory allowance", and I'll take that trade.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 6: Letting AI agents in without leaking your keys
&lt;/h2&gt;

&lt;p&gt;This is the bit I'm most excited about.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;gostman mcp&lt;/code&gt; serves a collection over the Model Context Protocol, so Claude Code, Codex or Cursor can call your saved requests. You can say "hit the staging login endpoint and check the response shape" and it just does it.&lt;/p&gt;

&lt;p&gt;The problem is that responses, error messages and request timelines are full of secrets. Anything that reaches the agent can end up in a model's context, and I didn't want anyone having to explain why the AI saw their production API key.&lt;/p&gt;

&lt;p&gt;My first attempt redacted by name: hide the &lt;code&gt;Authorization&lt;/code&gt; header, hide fields called &lt;code&gt;password&lt;/code&gt;. That broke constantly. Tokens show up in query strings, inside JSON bodies, in error messages that echo the URL back, and base64-encoded inside a Basic auth header.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgdio5i7uej8xjua1u8tu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgdio5i7uej8xjua1u8tu.png" alt="Redact what a secret is, not what it is called" width="800" height="604"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So the matching works on values instead. Names still decide what gets learned, like environment variables, secret-manager references (Vault, AWS, Azure, Google Cloud, 1Password, Bitwarden, KeePassXC) and auth fields. But what gets matched in the output is the actual value, plus the common forms it takes once encoded:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;variants&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;shortest&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;candidates&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;candidates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;percentEncoded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;candidates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;jsonEscaped&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;candidates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;base64Encoded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's percent-encoding, a few JSON-escaped forms, and base64 at all three byte alignments, which is what catches a password buried inside a Basic auth header. Then any text on its way out, whether it's a response body, a header, a timeline entry or an error, has every known form swapped for a marker like &lt;code&gt;{{apiKey}}&lt;/code&gt;. Overlapping matches get merged so a token sitting inside a longer token can't leave half of itself visible.&lt;/p&gt;

&lt;p&gt;The agent sees that &lt;code&gt;{{apiKey}}&lt;/code&gt; was used and that the request came back 200, and it never sees a value Gostman knows about. The limit is just as clear: it can't redact something it never learned, or a value that's been transformed into something else, like an HMAC signature.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing nobody warned me about: Go versions
&lt;/h2&gt;

&lt;p&gt;The Wails v2 CLI can't load packages on Go 1.25 or newer. So the whole module is pinned to Go 1.24.5, and that one constraint spread. goja is pinned to the last commit that still builds on 1.24. The official MCP Go SDK needs 1.25, so the &lt;code&gt;mcp/&lt;/code&gt; package is a small MCP implementation I wrote myself.&lt;/p&gt;

&lt;p&gt;Writing my own MCP server felt like a step backwards at first. But Gostman only needs a small part of the protocol (stdio by default, Streamable HTTP on loopback with a bearer token if you want it), and the small surface made it easy to keep every tool result and error flowing through one place in the tool layer, which is where the redactor sits.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell myself in April 2025
&lt;/h2&gt;

&lt;p&gt;Put the differences between environments in a value, not in &lt;code&gt;if&lt;/code&gt; statements. The &lt;code&gt;Policy&lt;/code&gt; struct is the only reason this didn't turn into four engines.&lt;/p&gt;

&lt;p&gt;Check security at the point where the connection actually happens. Checking a URL and checking a socket are not the same thing.&lt;/p&gt;

&lt;p&gt;Copies of the same logic will drift. Not "might". Will.&lt;/p&gt;

&lt;p&gt;And figure out your toolchain constraints early. One GUI framework's Go version ended up shaping three other dependencies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Gostman is free and open source, and a solid Postman alternative if you don't want an account. Data-driven runs, load testing, a mock server, a gRPC client with a GUI, secret managers and the MCP server are all in the free version.&lt;/p&gt;

&lt;p&gt;Source code (MIT): &lt;a href="https://github.com/kunalgoesbyken/gostman" rel="noopener noreferrer"&gt;github.com/kunalgoesbyken/gostman&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Test an API online with no sign-up: &lt;a href="https://gostman.com/web" rel="noopener noreferrer"&gt;gostman.com/web&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you've built something like this, especially an SSRF guard or a script sandbox, I'd love to hear how you approached it. And if you try Gostman and something's missing, open an issue, I read all of them.&lt;/p&gt;

</description>
      <category>go</category>
      <category>opensource</category>
      <category>api</category>
      <category>architecture</category>
    </item>
    <item>
      <title>I built a free, open-source Postman alternative in Go: no account, collections are just YAML files</title>
      <dc:creator>Kunal</dc:creator>
      <pubDate>Mon, 26 Oct 2026 17:09:13 +0000</pubDate>
      <link>https://dev.to/kunalgoesbyken/i-built-a-free-open-source-postman-alternative-in-go-no-account-collections-are-just-yaml-files-2kl7</link>
      <guid>https://dev.to/kunalgoesbyken/i-built-a-free-open-source-postman-alternative-in-go-no-account-collections-are-just-yaml-files-2kl7</guid>
      <description>&lt;p&gt;I got tired of API clients that want an account before I can send a single request, so I built &lt;strong&gt;Gostman&lt;/strong&gt;: a free, MIT-licensed API client for REST, GraphQL, gRPC and WebSocket. It runs as a desktop app, in the browser, and as a CLI.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Web app (no install, no account): &lt;a href="https://gostman.com/web" rel="noopener noreferrer"&gt;https://gostman.com/web&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Source: &lt;a href="https://github.com/kunalgoesbyken/gostman" rel="noopener noreferrer"&gt;https://github.com/kunalgoesbyken/gostman&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Protocols:&lt;/strong&gt; REST, GraphQL (with a schema browser), gRPC (reflection or .proto files), WebSocket, Socket.IO, MQTT over WebSocket, Server-Sent Events, SOAP and MCP servers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth:&lt;/strong&gt; Basic, Bearer, API key, OAuth 2.0 with PKCE, OAuth 1, Digest, AWS SigV4, NTLM, JWT Bearer and more.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Collections are plain YAML in a folder&lt;/strong&gt;, so they diff and review in Git like any other code. The desktop app has a Git panel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Testing:&lt;/strong&gt; assertions, pre-request and post-response scripts, and a collection runner with CSV or JSON data files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI:&lt;/strong&gt; &lt;code&gt;gostman run&lt;/code&gt; exits non-zero on failure and writes text, JSON, JUnit and HTML reports.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extras:&lt;/strong&gt; load testing (&lt;code&gt;gostman load&lt;/code&gt;), a secret scanner (&lt;code&gt;gostman scan&lt;/code&gt;), a mock server, traffic capture and OpenAPI import.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secrets:&lt;/strong&gt; OS keychain, HashiCorp Vault, AWS, Azure, Google Cloud, 1Password, Bitwarden and KeePassXC.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Import:&lt;/strong&gt; Postman, OpenAPI, Insomnia, HAR, Bruno, .http files and curl.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code generation&lt;/strong&gt; for 13 languages, including cURL, Python, Go and Rust.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Using it with coding agents
&lt;/h2&gt;

&lt;p&gt;I use coding agents every day, and the awkward part is handing them API credentials. &lt;code&gt;gostman mcp&lt;/code&gt; exposes a collection to agents like Claude Code, Codex or Cursor so they can call your requests without seeing your secrets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Under the hood
&lt;/h2&gt;

&lt;p&gt;The desktop app is built with Go and &lt;a href="https://wails.io" rel="noopener noreferrer"&gt;Wails&lt;/a&gt;. The CLI is a single binary, so you don't need Node.js:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go install github.com/kunalgoesbyken/gostman/cmd/gostman@latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are also builds for Windows, macOS and Linux (.deb, AppImage, AUR). They aren't code-signed yet, so SmartScreen or Gatekeeper will ask you to confirm on first run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits worth knowing
&lt;/h2&gt;

&lt;p&gt;The web version sends requests through a small relay, so it can't reach localhost or private networks. Use the desktop app for those. Responses over 3 MB are truncated in the web version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feedback welcome
&lt;/h2&gt;

&lt;p&gt;It's early and I'm building it in the open. If something is missing from your workflow, or an import from Postman or Bruno doesn't work, open an issue: &lt;a href="https://github.com/kunalgoesbyken/gostman/issues" rel="noopener noreferrer"&gt;https://github.com/kunalgoesbyken/gostman/issues&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you try it, I'd like to hear which features you actually use.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>go</category>
      <category>api</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
