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.
It started with a login screen.
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.
I wasn't even angry, I was curious. How hard can an API client be?
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.
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.
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 Policy 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.
So yeah, why Go?
An API client is mostly networking. Timeouts, redirects, TLS, proxies, cookies, cancellation, streaming. Go's standard library already has all of it, net/http, crypto/tls, context, a cookie jar, and none of it is magic.
I also wanted gostman run 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.
For the desktop app I picked Wails. 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 and behind a serverless function. That ended up being the entire story.
THE ARCHITECTURE:
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.
Phase 1: A desktop app that sends requests
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.
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.)
It worked, it was fast, and I was happy with it. Then people started asking for a version they could try without installing anything.
Phase 2: The browser version, and my biggest mistake
Which is fair. I wouldn't install a random app from a stranger on Reddit either.
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.
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".
It was not basically the same code.
Copies of the same logic don't stay the same for long. Mine drifted slowly and quietly, and then all at once.
Binary responses (images, PDFs, anything that isn't text) came back corrupted, because both Go copies did string(bodyBytes) on the response and handed the UI mangled text.
Big integers changed value on the way out. Variables and GraphQL variables were decoded into floats, JSON.parse on the JavaScript side and json.Unmarshal into map[string]any on the Go side, and a 64-bit float can't hold every integer above 2^53. So a database ID like 9007199254740993 got sent as 9007199254740992. A different ID, with no error anywhere. That one was really fun to track down.
And cookies set during a redirect just vanished. Neither copy had a cookie jar, so a Set-Cookie on the first hop was never sent to the second.
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.
Phase 3: One engine
The fix was boring on purpose. There is now exactly one piece of code that builds and sends a request, a Go package called engine/, and from the outside it's one function:
func Send(ctx context.Context, r Request, policy Policy) Response 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 http.Request.
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.
The Policy struct
The obvious question: if the desktop app and a public web proxy run the same code, how does the desktop app still reach localhost:3000 while the public proxy refuses to?
Everything that's different between environments goes into one value:
type Policy struct { BlockPrivate bool MaxResponseBytes int64 MaxTimeout time.Duration Permit func(host string) bool RevealSecrets bool MaxStreamDuration time.Duration RunCommands bool RawSockets bool ... } The desktop app passes a relaxed one. The hosted proxy passes this:
var Policy = engine.Policy{ BlockPrivate: true, RecordBody: true, MaxResponseBytes: 3 << 20, MaxTimeout: 30 * time.Second, MaxStreamDuration: 60 * time.Second, MinStreamRetry: time.Second, } 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 Policy, not adding if isWeb checks all over a package of nearly 9,000 lines.
Why it works
The big one for me: if a request passes in the desktop app, gostman run 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 decoder.UseNumber(), 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.
Phase 4: Putting a proxy on the internet (the SSRF problem)
Here's the scary part. The web proxy fetches whatever URL a stranger sends it. Without care, anyone can ask my server to fetch http://169.254.169.254/ (the cloud metadata endpoint) or something on an internal network. That's SSRF, server-side request forgery.
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.
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 127.0.0.1 a few milliseconds later when the HTTP client actually connects. That's DNS rebinding, and the check-then-connect approach walks right into it.
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):
func guardedDial(ctx context.Context, network, address, family string) (net.Conn, error) { host, port, err := net.SplitHostPort(address) if err != nil { return nil, err } addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host) if err != nil { return nil, err } for _, candidate := range addresses { if isBlockedIP(candidate.IP) { return nil, errBlockedAddress } } for _, candidate := range addresses { conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(candidate.IP.String(), port)) if err == nil { return conn, nil } } ... } Two details turned out to matter a lot more than I expected.
First, refuse the host if any 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.
Second, the standard library checks are a good start but not enough. An address is blocked if it's not IsGlobalUnicast() (that catches loopback and link-local, including the 169.254.169.254 metadata endpoint) or if it's IsPrivate(). That still misses carrier-grade NAT (100.64.0.0/10), the benchmarking range (198.18.0.0/15), NAT64 (64:ff9b::/96), 6to4 (2002::/16), IPv4-translated IPv6 (::ffff:0:0:0/96) 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 ::ffff:127.0.0.1 are already handled, because Go normalises them to 4 bytes before those checks run.)
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.)
Phase 5: The CLI, and running a stranger's JavaScript
Once the engine existed, gostman run 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.
People coming from Postman expect pre-request and post-response scripts with test() and expect(). 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 goja, a JavaScript engine written in pure Go. The CLI embeds (//go:embed) a bundle built from the same JavaScript modules the apps run, so the test()/expect() helpers exist once. Same lesson as Phase 2: one copy, so nothing can drift.
The catch is that scripts come from imported collections, which means from strangers. And strangers write things like
while (true) {} or my personal favourite
const a = []; for (;;) a.push(a) Neither one is allowed to hang your CI job or eat your laptop's memory.
goja can stop a running script with vm.Interrupt(), so every script runs under a watchdog with two limits:
func watch(vm *goja.Runtime, limit time.Duration) *watchdog { guard := &watchdog{vm: vm, ticker: time.NewTicker(memoryInterval), done: make(chan struct{})} guard.timer = time.AfterFunc(limit, func() { guard.interrupt(errExpired) }) ceiling := heapInUse() + memoryAllowance go guard.audit(ceiling) return guard } Time is the easy one, time.AfterFunc 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 runtime/metrics every 50 ms and interrupts the script if the heap is more than 512 MB above where it was when the script started.
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 a.push(a) 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.
Phase 6: Letting AI agents in without leaking your keys
This is the bit I'm most excited about.
gostman mcp 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.
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.
My first attempt redacted by name: hide the Authorization header, hide fields called password. 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.
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:
func variants(value string, shortest int) []string { candidates := []string{value} candidates = append(candidates, percentEncoded(value)...) candidates = append(candidates, jsonEscaped(value)...) candidates = append(candidates, base64Encoded(value)...) ... } 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 {{apiKey}}. Overlapping matches get merged so a token sitting inside a longer token can't leave half of itself visible.
The agent sees that {{apiKey}} 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.
The thing nobody warned me about: Go versions
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 mcp/ package is a small MCP implementation I wrote myself.
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.
What I'd tell myself in April 2025
Put the differences between environments in a value, not in if statements. The Policy struct is the only reason this didn't turn into four engines.
Check security at the point where the connection actually happens. Checking a URL and checking a socket are not the same thing.
Copies of the same logic will drift. Not "might". Will.
And figure out your toolchain constraints early. One GUI framework's Go version ended up shaping three other dependencies.
Try it
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.
Source code (MIT): github.com/kunalgoesbyken/gostman
Test an API online with no sign-up: gostman.com/web
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.







