Kotae: The 12.7 MB Postman Alternative That Skips Electron and the Cloud
theme: condensed-night-purple
Origin
I deal with a bunch of internal network endpoints daily. Postman has turned more and more into a collaboration platform: accounts, cloud sync, workspaces, team spaces, slow startup, and heavy on memory; under the domestic network, the login state drops from time to time. Insomnia later went down the same path.
What I actually want is tiny: a double-click local program that can compose requests, send packets, view structured responses, manage collections and environment variables, paste a cURL to restore, and all data stays only on my machine.
So came Kotae (Japanese "答え", answer): a pure-local lightweight API debugging tool, all-Chinese interface, no cloud, no backend, no account. Currently at 0.2.3, with installers for Windows / macOS.
The base is MyGo (a Go desktop application framework, currently 0.7.4): the window uses the system webview, the frontend is an ordinary Vite project, and packaging / installer / auto-update / tray / single-instance are handled by the framework.
Let me give a few numbers so the later technical trade-offs have context:
- The executable
Kotae.exe12.7 MB (the Vite-built frontend is already embedded in the binary) - NSIS installer 4.8 MB
- Frontend runtime dependencies are only 3 MyGo plugins +
mygo-runtime, no UI framework, no component library, no icon library - End-to-end test cases 105, running real binary + real windows + real Go requests
Technical form: Go desktop shell + system WebView
The stack uses Go to write the desktop shell (the MyGo framework) plus an ordinary Vite / TypeScript frontend running in the system webview.
The fundamental difference from Electron: it does not bundle Chromium. The window uses the system's built-in webview (on Windows it's WebView2, preinstalled on Win11), so the product does not contain that 100 MB-grade kernel; the frontend dist is only 325 KB, compressed and shipped with the binary.
The costs should also be stated clearly:
- Behavior is governed by the system webview; on Windows you get whatever WebView2 version is available (a performance trap later ties to this)
- There is no Node.js runtime; any "local capability" must be exposed as a method from the Go side
- Packaging, signing, and updates must follow the framework's conventions; you can't directly reuse the Node ecosystem's scaffolding
For a small local HTTP debugging tool, this trade is very worthwhile.
Layering: Go only does platform capabilities, business logic never touches Go
The project set a line from the start, and all subsequent code grows around it:
The Go side does not parse business data, does not touch collections and tabs, only provides platform capabilities; business logic (collections, history, environments, sending, interpolation, persistence) is written only in
src/store.ts.
main.go is nothing but assembly:
jar := newToggleJar()
// All requests are sent by Go: not subject to CORS, can write any request header.
// Timeout is not placed on http.Client; the frontend sets an AbortSignal per request,
// so each request can have a different timeout.
client := &http.Client{Transport: newRoutingTransport(netproxy.Transport()), Jar: jar}
mygo.Use(fetch.New(fetch.Options{Client: client}))
mygo.Use(sqlite.Plugin)
// WebSocket is also established by Go: it can carry handshake headers and is not subject to same-origin policy.
mygo.Use(websocket.New(websocket.Options{Client: client}))
mygo.Use(updater.New(updater.Options{Language: "zh"}))
mygo.Bind(&System{jar: jar})
The methods bound on System number only about a dozen: clipboard, save/open file, select file, open data directory, system notification, cookie jar switch, single-instance ready. None of them know what a "collection" is.
On the frontend side, the bindings are generated: npm run generate compiles the Go public methods into src/mygo.ts, with a header that says DO NOT EDIT. If you change a Go signature and forget to re-run the generation, the frontend gets the old contract. This is written into the project's hard rules.
Business logic is all in TypeScript. The hot file src/store.ts is 2,800 lines, managing collection trees, tabs, environments, interpolation, send plans, SSE/WebSocket streams, layered script execution; src/ui/* only renders and forwards events. This boundary saved me after half a year: changing UI, changing request sending, changing storage never fought in the same file.
Decision one: requests must be sent by Go
Doing API debugging in a browser has four hard problems:
- CORS: cross-origin preflight blocks it, and many internal endpoints don't even have
Access-Control-Allow-Originconfigured - Restricted request headers:
Origin,Cookie,User-Agent,Referercannot be changed, and these are exactly what you often need to fake when debugging interfaces - Cannot read
Set-Cookie: this header in the response is invisible to JS - Cookies belong to the browser: login state mixes with your daily browsing, and you can't just clear it
So requests are sent from the Go side, and the frontend just assembles parameters. Four problems disappear at once.
But "handing it to Go" does not mean just throwing net/http at it; there are three non-obvious points in the middle.
Timeout is not on http.Client
http.Client.Timeout is at the client level; one Client can only have one. "Per-request timeout" is a basic requirement of this tool: a stock query endpoint takes 1 second, a report export endpoint takes 60 seconds.
So Client.Timeout is left empty, and the frontend measures time with AbortSignal; when canceling, the signal is passed down.
How per-request proxies fit into a shared Client
Proxy and "skip TLS verification" are options on the UI for each request, but the fetch plugin has only one shared http.Client. Two routes: either modify the framework (not worth it), or "smuggle" request-level options before they reach the Transport.
The latter was chosen: the frontend carries two internal headers per request, and routingTransport.RoundTrip strips them, then switches to a corresponding *http.Transport based on (proxy, insecure):
func (rt *routingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Clone before touching headers: the original request remains in http.Client's redirect chain, and mutating it pollutes the via.
out := req.Clone(req.Context())
proxy := strings.TrimSpace(out.Header.Get(proxyHeader))
// ... get insecureHeader, if both empty just go through base
if proxy != "" || insecure {
next, err := rt.transportFor(proxy, insecure)
if err != nil { return nil, err }
return next.RoundTrip(out)
}
return rt.base.RoundTrip(out)
}
transportFor caches *http.Transport by key (connection pools should be reused, not created per request), and validates that the proxy protocol only allows http / https / socks5; if invalid, it returns a Chinese error: Proxy address %q is invalid, example: http://127.0.0.1:7897.
These two headers are removed before sending, the server never sees them, and the user never sees them.
The redirect chain must be collected manually
http.Client performs redirects itself, and the page only receives the final response. But "how many times this request jumped, where each hop landed, how long DNS and TLS took" is key for debugging proxy and gateway issues.
The collection point can only be in the Transport: the frontend gives each request a trace id stuffed into X-Mygo-Trace-Id, and the Transport attaches httptrace.ClientTrace to that hop:
func (t *hopTimer) trace() *httptrace.ClientTrace {
return &httptrace.ClientTrace{
DNSStart: func(httptrace.DNSStartInfo) { t.dnsStart = time.Now() },
DNSDone: func(httptrace.DNSDoneInfo) { t.dnsDone = time.Now() },
GetConn: func(string) { t.connStart = time.Now() },
GotConn: func(info httptrace.GotConnInfo) { t.connDone = time.Now(); t.reused = info.Reused },
TLSHandshakeStart: func() { t.tlsStart = time.Now() },
TLSHandshakeDone: func(cs tls.ConnectionState, err error) { t.tlsDone = time.Now() },
GotFirstResponseByte: func() { if t.firstByte.IsZero() { t.firstByte = time.Now() } },
}
}
One record per hop (method, URL, status code + DNS / connect / TLS / first byte / total, and GotConn's Reused can mark "this didn't reconnect"), accumulated by trace id, then JSON + base64 put back into X-Mygo-Trace response header, and the page decodes it and renders into the "Chain" tab.
Two companion decisions:
- Internal headers are uniformly prefixed with
X-Mygo-, and the page filters this batch when rendering the response header list. Otherwise users would see a pile of framework internal fields in their own response headers, very dirty. - The trace map has an upper limit (
traceLimit = 64), discarding the oldest by insertion order. Even if the page doesn't fetch in time (the user closed the tab), memory won't keep growing.
Decision two: WebSocket also goes to Go
The browser's new WebSocket(url, protocols) has no headers parameter. That means:
Authorizationcan't be carried in (WS services that require auth can only use query params to pass tokens, which is often not the server's intended usage)Cookiecan only be auto-carried by the browser; you can't specify it- Subprotocols can only be passed via the second parameter; no room for extra headers
This is fatal for an API debugging tool. So WebSocket handshakes and frame encoding/decoding are handled on the Go side, and it reuses the same http.Client — that sentence is the point: the cookie jar is shared, so when the page calls a login endpoint first and gets cookies, the WS connection automatically carries them into the handshake, no manual copying.
Usage looks like this: the protocol switches from HTTP to WebSocket, handshake headers are the ordinary request header table (Authorization, Cookie whatever you like), subprotocols come from Sec-WebSocket-Protocol; once connected, messages are separated into "receive / send / system" categories, and the input box at the bottom of the panel sends on Enter.
Boundaries must be defined, no unbounded reception: a single connection keeps at most 500 messages, each at most 256 KB; beyond that, truncate and show a hint in the UI.
SSE stays in the frontend as-is: it's just an ordinary HTTP response stream, the page parses event chunks itself (event / data / id three field types, comment lines treated as heartbeats and discarded, multi-line data joined into one), reusing the same fetch, the same cancel button. Opening a separate Go channel for it yields no benefit.
Decision three: storage switched from JSON to SQLite, with diff-based commits
Version 0.1 stored everything in workspace.json under userData: every change was fully serialized and fully written to disk.
Three problems showed up early:
- Continuous edits (dragging splitters, typing variables) trigger multiple full writes within tens of milliseconds
- A crash mid-write = the entire workspace is corrupt, because the overlay is atomic but the content is monolithic
- For statistics, grep, or manual data fixes, you're stuck with a text editor
The storage layer is now SQLite, using MyGo's sqlite plugin: a precompiled sqlite3 is loaded via purego, no cgo needed, and mygo build places the native library for the platform (on Windows it's mygo-sqlite3.dll, about 1.4 MB) next to the product and packs it in. Users get it working after install, no separate database installation needed.
Tables are split into collections / folders / requests / history / environments / variables / tabs / settings / meta; the collection tree's hierarchy and order are restored by a position column, and folders and requests share one sequence space.
What's worth writing down is the three properties of save() (error handling omitted below):
export async function save(data: PersistedWorkspace): Promise<void> {
const next = rows(data); // in-memory snapshot expanded into rows, each row carries a signature
const previous = written; // the last successful written rows
const statements: Statement[] = [];
const kept = new Set<string>();
for (const row of next) {
const key = `${row.table}:${row.id}`;
kept.add(key);
if (previous.get(key) === row.signature) continue; // content unchanged, skip
statements.push({ sql: row.sql, params: row.params });
}
// present before, absent now = deleted rows (or history trimmed)
for (const key of previous.keys()) {
if (kept.has(key)) continue;
const cut = key.indexOf(":");
statements.push({ sql: `DELETE FROM ${key.slice(0, cut)} WHERE id = ?`, params: [key.slice(cut + 1)] });
}
if (statements.length === 0) return;
await db.transaction(statements); // BEGIN IMMEDIATE, entire batch in one transaction
written = new Map(next.map((row) => [`${row.table}:${row.id}`, row.signature]));
}
One: don't write if content hasn't changed. Each row carries a signature, which is JSON.stringify of that row's content, without timestamps. Otherwise every save would rewrite the entire database because updated_at changed: the first version hit this trap, showing up as "only one environment variable changed, and the database file grinds the disk every second".
Two: deletions must use DELETE. Doing only INSERT … ON CONFLICT DO UPDATE would leave deleted collections in the database, and they'd "resurrect" after restart. So a diff is computed by table:id, and the leftovers are treated as deletions.
Three: one transaction for the entire batch; on failure, don't update the fingerprint. When a write throws, written keeps the old value, and the next save retries the entire batch of diffs. This matters more than "rollback": it guarantees the invariant "what you see in memory = what's in the database" will eventually converge.
A 220ms debounce on top batches continuous edits:
saveTimer = window.setTimeout(() => { /* real save */ }, 220);
The migration path is also fixed: on first launch, the old workspace.json is read and migrated into the database, then the original file is renamed to workspace.json.bak (not deleted); if parsing fails, skip migration and show a hint in the UI. There is only one entry point for version checking: meta.schema_version; guessing versions by "whether a table exists" is forbidden.
Decision four: scripts don't pretend to be sandboxed
Version 0.2.x added Postman-style pm scripts: pre-scripts compute signatures and modify requests; post-scripts decrypt responses, take a 401, swap tokens, and resend.
Implementation took a not-very-"secure" but honest decision: scripts run directly in the page via AsyncFunction, no sandbox isolation. The same code can reach window, localStorage, and the entire document DOM.
Why not wrap an iframe + sandbox or another isolation layer? Because true isolation would require serializing all of pm's capabilities across a boundary, and handling async, object references, error stacks: that's the engineering effort of another project, and the security it gives is fake (the isolation layer itself can be bypassed).
So the boundary is made explicit:
Scripts are the user's own local JS. So any script that comes from an imported file (Postman Collection / HAR) is marked "untrusted" and is not in the execution chain; only after the user clicks "trust and enable" in the script library will it execute.
This is a security model a user can understand in a second, not a statement like "we have a sandbox".
Two implementation details worth mentioning:
1. 5-second budget, but network wait time isn't counted.
const SCRIPT_TIMEOUT_MS = 5000;
The problem is a script can await pm.sendRequest(...) to actually send another HTTP request. If the timer keeps running, a single slow login request can drag the whole script into timeout. So before sending a request, the timer is disarmed; after returning, the spent time is deducted and the timer is re-armed:
The script itself runs at most 5 seconds; network wait is not counted.
2. pm.execution.resend(), so a 401 can trigger a login and re-run the chain.
After a post-script gets a 401, it can pm.sendRequest to call the login endpoint, get a token, write it to an environment variable, then call pm.execution.resend() to send the same request again. Resend is at most 3 times, and variables are re-interpolated.
This "auto-refresh and retry on expired token" flow in other tools either requires building it with collection-level pre-requests, or the user clicks twice. Here it's a three-step script:
if (pm.response.code === 401) {
const res = await pm.sendRequest({
url: "{{baseUrl}}/auth/refresh",
method: "POST",
body: JSON.stringify({ token: pm.environment.get("refreshToken") }),
});
pm.environment.set("accessToken", res.json().access_token);
pm.execution.resend();
}
One interaction decision: pre/post operations are layered ordered lists: global → collection → folder (outer → inner) → request; post-scripts run in reverse, and within a layer they follow the list order. Each layer can mix "script", "script library reference", and "wait time" as steps, one line per step, with arrows to expand in place, and a … menu to move up/down.
No drag-and-drop reordering. These lists usually have only three to five lines; drag targets are too small and misoperation cost is high; up/down buttons are both precise and fast.
Decision five: design system enforced by mechanical gates, not by willpower
With one person maintaining and AI helping change CSS, the most realistic risk isn't "can't write a UI" but "three months later you have a second color scheme, four border radii, five font sizes".
So there's an authoritative document docs/设计系统.md first, with hard lines:
| Dimension | Stance |
|---|---|
| Accent color | Only one: indigo. No second accent color is approved |
| Border radius | Small rounded, hard-edged feel, 6 levels |
| Shadow | only for "floating" elements, not for section separation |
| Animation | ≤ 180ms, ease-out, no bounce |
| Copy | All Chinese, verb-first, one sentence finishes the point |
Tokens have three layers: global physical values appear only in :root and theme blocks → alias tokens (--accent / --text-2 / --fs-md) → component rules may only reference the alias layer.
But documentation is just documentation. What actually governs is the script scripts/check-ui-tokens.mjs, run by npm run lint:ui, passing both in CI and before commits:
#hex/rgb()/hsl()appearing outside the token section → violationfont-size: 12px→ violationborder-radius: 7px(except50%) → violation- bare
box-shadow→ violation var(--xxx)referencing an undefined token → violation- unregistered animation and duration → violation (
fade-inonly allowed at 120ms,pop-inonly at 90/180ms,spinonly at 0.7s)
Each violation carries a file line number and a one-line fix hint, like:
src/style.css:<line> Bare color #3b82f6 → use var(--accent/--text-2/--border …), or define a semantic token first in the token section
src/style.css:<line> Bare font-size: 12px → use var(--fs-2xs|xs|sm|md|lg|xl|2xl)
One sentence summary of this experience: "keeping style consistent" is not a rule; what can exit 1 is a rule.
Another companion gate is "both themes must be checked": screenshots are generated by e2e, one set each for light and dark; committing after verifying only one theme counts as unverified.
Two WebView2 performance traps
One: cold GPU cache causes a long first frame
The symptom is odd: when a user first clicks "New Script", there's a stutter; later clicks don't stutter. Every e2e run uses a fresh userData, so every run reproduces it.
The root cause is that WebView2's GPU shader/raster cache (the GPUCache under userData) is cold; a certain type of widget has to compile shaders on first draw, measured once as a 74ms-class long frame.
The fix isn't "optimize rendering" but "pay this cost in advance": after startup completes, before the user does anything, draw the shapes of common widgets for two frames and then discard them.
The difficulty lies in two prerequisites; missing either one makes it worthless:
- The node must be in the viewport and really drawn.
display: none,opacity: 0, or moving it toleft: -4000pxwon't trigger drawing - It must draw for two frames: the first frame submits the draw, the second ensures the result lands on the compositor
So the pre-warm layer uses opacity: 0.01 + pointer-events: none (invisible to the eye, but actually drawing), kept for two frames, then removed. Measured result: "New Script" first frame dropped from 74ms to 0.
The long-term cost: from now on, any widget that is first drawn after the initial screen must be added to the pre-warm list, otherwise that 70ms will leak to users as a "random stutter".
Two: the cost of backdrop-filter
The modal overlay originally had a layer of backdrop-filter: blur(). Visually it almost didn't show, since the overlay already darkens the background, but on software-rendered machines, it took several hundred milliseconds per frame, showing up as "the switch overlay turns into a slideshow when opening the settings panel".
After removing it, no visual perception difference, and interaction is noticeably smoother. Blur is the lowest-cost visual effect in this kind of tool: it costs several hundred milliseconds but only provides a "more expensive" look.
Testing: real binary + real windows + real Go requests
No Playwright / Cypress. Reason: we're testing a desktop application, not a web page.
- Deep links (
kotae://import?curl=...), single-instance locks, tray, close-to-tray, "is the frontend really inside the binary after packaging" — none of these can be tested by page-level browser automation - Conversely, page-level capabilities (reading/writing DOM, screenshots) are sufficient
The approach: the application links MyGo's Automation plugin under //go:build e2e, only in that tag; the release build doesn't have that tag, so the artifact contains no inspector. In tests, MYGO_AUTOMATION points to a Unix socket, and the protocol is newline-delimited JSON. The page window can only do three things: eval (execute page scripts), capture (screenshot), menu (click menus) — it can't click native controls.
Isolation is done cleanly:
- Each run uses a different app name (single-instance locks are per name, so a real open app can run in parallel), plus a temporary userData directory
- Fixed test data is deliberately fed in old
workspace.jsonformat, so every run genuinely exercises the migration path - Stub services live in
e2e/echo.go: echo requests, slow responses, redirects, error codes, cookies, binary, five-level nested JSON, HMAC verification; streaming endpoints ine2e/stream.go: SSE pushes item by item, and WebSocket uses a hand-written RFC 6455 handshake and frame codec, no third-party library
One testing discipline written into the rules: wait conditions must anchor to "the observable difference caused by this operation". No > 0, no "just check the element exists", no sleep. If A changed, assert A changed; don't use B as a stand-in.
And one reverse rule: changing src/ sample data or default behavior just to make a test green is cheating.
Now a default run has 105 cases, from "every widget on every screen has a name, a size, and a center point that can actually be clicked" to whole-chain scenarios like "post-script detects 401, logs in, writes environment variable, resends to 200". Packaging cases (MYGO_E2E_PACKAGING=1, assert the UI comes from the binary) and README screenshot cases are separate switches and don't occupy the daily run.
Explicitly not done
Boundaries are written in the docs and in the README:
- Request recording proxy
- Collection runner and batch execution
- Load / performance testing
- Cloud sync and team collaboration, account system
- OpenAPI request generation
- Full assertion framework
The reason for each is one sentence: these all turn a "lightweight local tool" into a "heavyweight testing platform". And "lightweight, pure-local" is this project's only reason to exist.
Known costs are also honestly recorded in the README, not hidden:
- The installer has no code signature; Windows first run will trigger SmartScreen once ("More info" → "Run anyway")
- macOS is unsigned and not notarized; first open requires right-click → "Open"
- Linux tray needs
libayatana-appindicator3; if not installed, tray creation fails and only logs; everything else works normally
Summary
One person + one agent, from project start to 0.2.3, 105 e2e cases. Looking back, the reusable experience is roughly these:
- In a desktop shell, capabilities that can be handed to the native side shouldn't be worked around in the page. Sending packets, WebSocket, cookie jar go to Go; problems like "CORS error" simply never appear.
- When the framework gives you only one shared Client, smuggle request-level options into the Transport; don't modify the framework for a single request-level toggle.
- "Don't write if content hasn't changed" matters more than "write faster". Fingerprint + diff + single transaction, these three properties are more substantial than any caching strategy.
- If you can't build a sandbox, say it clearly. Converging the security boundary to a toggle a user can understand (trusted/untrusted) is more responsible than building a fake isolation layer.
- Style consistency relies on lint, not willpower. What can
exit 1is the rule. - Pay the cold-cache first-frame cost in advance. Desktop webview performance problems are often not "algorithm is slow" but "first-time shader compilation".
- Either test real things, or don't test. Real binary, real windows, real Go requests: this is the only path that proves "usable after packaging" for this desktop stack.
Project on GitHub: fayvo-hub/Kotae; Releases contain Windows installer / standalone single-file exe and macOS dmg. If you're willing to try it, feel free to file an issue; all data is in the local workspace.db, and if you install and aren't satisfied, deleting the data directory leaves a clean slate.
Top 1 from juejin.cn, machine-translated. The original thread is authoritative.
Ultra-lightweight Postman, welcome to try and give a star. https://github.com/fayvo-hub/Kotae