Yoo dum dum,

You really want to know how to make Kairo? Like, really? Before you copy my homework, you should probably know why I actually made this thing.

Creator Avatar Graphic

Basically, I was doing some freelance work, and the clients wanted me to learn Rust because the project required it. So, yeah, I was sitting there pretending to follow tutorials, and then I realized: why am I just wasting time reading when I could just build something real? I stopped looking at the toy examples, started writing code, and ended up making Kairo. That is the entire origin story.

So instead of doing my actual job, I fell down a massive architecture hole. If you came here looking for a clean, formal developer case study, you are in the completely wrong place. But if you want to see how a two-day procrastination detour turned into a fully operational cross-platform client, stick around.

Let's start from the actual start

I didn't sit down to build "a multi-protocol API client with a scripting sandbox and a load-testing suite." That came much later. The actual pitch, to myself, was a lot dumber: I just wanted to make Postman, but fast. Really fast. And I wanted an excuse to actually use whatever Rust optimization tricks I'd been reading about instead of just reading about them.

So I opened a blank file, dumped every idea I had into what I'll charitably call my brain.txt, and built the smallest possible thing that could send an HTTP request and show you what came back. I will call it crap, because it was crap: two inputs and one send button. That's genuinely it. Is that a Postman replacement? No. Was it mine? Yes.

kairo — day 1v0.0.1

that's it. that was the whole app.

kairo — todayv0.4.0
GET
{{base_url}}/v1/cats
Send
ParamsHeadersBodyAuthScripts
"statusCode": 200
"message": "OK"

same core idea. a lot more of everything else.

After that first version worked — after I actually saw a real response come back from a real API — I fell into the UI rabbit hole. Tabs. Params. Headers. Auth. Environments. A real response viewer instead of a raw text dump. None of it happened in one sitting; it happened in a loop of building something, hating how it looked, and rebuilding it slightly better. Somewhere in that loop I got better at Rust and better at UI at more or less the same time, mostly because I had no choice — there was no one else to hand either half to.

How Kairo is actually built

This is the part most "I built an app" posts skip, so let's actually go through it. Kairo is a Tauriapp, not an Electron one. The practical difference: Electron ships an entire Chromium browser and a Node.js runtime inside your app. Tauri ships neither — it uses the operating system's own WebView to render the UI, and everything else is a single native Rust binary. That's the actual reason Kairo starts in under two seconds and sits under 80MB of RAM at idle; it's not a marketing line, it's just what you get by not bundling a browser inside your app.

The UI layer is React, TypeScript, and Vite — nothing unusual there. State is split into small zustand stores per feature (tabStore, socketStore, environmentStore, and so on) instead of one giant global store, so a WebSocket message coming in doesn't re-render your request tabs. Monaco (the editor VS Code uses) handles the body and script editors. All of that lives in the WebView and knows almost nothing about how a request actually gets sent — it just asks the Rust side to do it.

Kairo architecture: React UI talks to a Rust core over Tauri IPCWebView — React + TypeScriptEverything the user sees. No business logic lives here.Request BuilderZustand storesMonaco editorRecharts (perf panel)Tauri IPC bridgeinvoke("execute_request", payload) → Promise · app.emit("ws-message", …) → listen()Rust core (src-tauri)One native binary. No Node.js, no Chromium runtime, no separate server process.HTTP enginereqwest + rustlshyper (h1 / h2)builds + sendsthe actual requestexecute_request()Storagerusqlite + r2d2pool, max 8 connsWAL journal modecollections, history,envs, settings, tabsScript sandboxrquickjs (QuickJS)embedded in-processno Node.js runtimeexposes pm.environmentrun_pre_request_script()Realtime enginetokio-tungstenite (WS)streamed SSE readeraxum (mock server)pushes events overapp.emit(), not invoke()any API on the internetkairo.dblocal SQLite fileWS / SSE peers,local mock listener

The interesting part is the bridge in the middle. Tauri gives the frontend two fundamentally different ways to talk to Rust, and Kairo uses both, deliberately:

  • invoke() — a request/response call, like calling a function that happens to live in another process. This is how execute_request, save_request, and most of the ~30 commands work: the UI calls it, awaits a promise, gets an answer back.
  • emit() / listen()— a one-way event stream from Rust to the UI. A WebSocket connection or an SSE stream doesn't have a single "response" to wait for — it has an open connection that keeps producing messages — so Rust just keeps calling app.emit("ws-message", …) every time one arrives, and the frontend store subscribes once and reacts to each event.

Mixing those two up is the easiest way to build a laggy-feeling app — if WebSocket messages went through invoke(), you'd be opening a new IPC round-trip for every single frame. Using emit() for streams and invoke() for one-shot actions is most of why the realtime panels feel instant.

What actually happens when you hit "Send"

This is the part of the app that runs the most, so it's worth walking through exactly what happens between clicking Send and seeing a response, step by step:

1

Load environment variables

Pull the active environment's key/value pairs from SQLite. If no environment is selected, start with an empty map.

storage::environments::get_variables(&pool, env_id)
2

Run the pre-request script

If the request has a pre-request script, spin up a fresh QuickJS context and run it before anything else — a script that sets pm.environment.set() needs to affect the request that's about to fire, not the next one.

run_pre_request_script(&script, &mut env_vars)
3

Substitute {{VAR}} placeholders

Walk the URL, headers, params and body, swapping every {{key}} for its value — using the (possibly script-updated) variable map from step 2.

substitute_vars_in_request(request, &env_vars)
4

Persist any variable changes

If the script changed a variable, write it back to the environment in SQLite, so the Environments panel and the next request both see it.

storage::environments::merge_variables(&pool, env_id, &env_vars)
5

Build the client and request

Construct a reqwest client (proxy / TLS / redirect settings from the request), attach query params, headers, auth, and body.

let client = build_client(&request)?;
6

Send it, and time it

Fire the request. An Instant is captured right before send() and read right after the first byte comes back — that's the TTFB you see in the performance panel.

let response = builder.send().await?;
7

Shape the response

Status, headers, cookies, and body get normalized into one ApiResponse struct — the same shape whether the server replied in 4ms or 4s.

ApiResponse { status, headers, cookies, body, timing }
8

Run the test script, ship it back

If there's a post-response script, it runs against the real response (assertions, extraction). The finished ApiResponse is serialized to JSON and returned across the IPC boundary as the resolved value of invoke().

Ok(response) // → Promise<ApiResponse> in the UI

The database

Collections, history, environments, settings, and even your open tabs are stored in a single SQLite file — kairo.db, sitting in the app's data directory, no server, no external database to run. The one deliberate choice here: instead of a single connection wrapped in a mutex (the "just make it work" option), Kairo pools connections with r2d2and turns on WAL (write-ahead logging) mode. In practice that means searching your request history doesn't block on a write that's happening because a request just finished — reads and writes can happen concurrently instead of queueing behind one lock.

src-tauri/src/db/mod.rsrust
let manager = SqliteConnectionManager::file(db_path).with_init(|conn| {
    conn.execute_batch(
        "PRAGMA journal_mode = WAL;
         PRAGMA synchronous = NORMAL;
         PRAGMA foreign_keys = ON;
         PRAGMA cache_size = -8000;",
    )
});

let pool = r2d2::Pool::builder().max_size(8).build(manager)?;

The scripting sandbox

Pre-request and test scripts need a real JavaScript engine — but pulling in Node.js just to run a few lines of user script would roughly double the size of the app for almost no benefit. Instead, Kairo embeds rquickjs, a Rust binding for QuickJS, directly in the binary. Each script runs in its own throwaway context with a tiny, deliberate API surface exposed to it — console.log for debugging and a pm.environment object for reading and writing variables — backed by a shared map on the Rust side.

src-tauri/src/commands/scripts.rsrust
let set_fn = rquickjs::Function::new(ctx.clone(), move |key: String, value: String| {
    set_map.borrow_mut().insert(key, value);
})?;
environment.set("set", set_fn)?;

That's the entire surface a script gets. No filesystem access, no network access from inside the script — it can read and write environment variables and print to the console, and that's it. Small blast radius on purpose.

Two bugs that taught me more than any tutorial did

Nothing teaches you a language faster than shipping something broken and having to figure out why. Two examples that actually happened while building this:

The environment that silently did nothing

The frontend sends requests to Rust as JSON, and JavaScript convention is camelCase environmentId. Rust convention is snake_case environment_id. Without telling serde (Rust's JSON library) to bridge that gap, it looked for a field literally called environment_idin the incoming JSON, didn't find one, and — because the field was an Option<String> — silently treated it as None instead of throwing an error.

Nothing crashed. No error in the console. It just meant every request quietly ignored whatever environment you had selected, and {{VAR}}placeholders never got substituted. That's the nastiest kind of bug — no stack trace to chase, just a feature that felt like it did nothing. One attribute fixed it:

src-tauri/src/commands/http.rsrust
#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExecuteRequestPayload {
    pub request: ApiRequest,
    pub environment_id: Option<String>,
}

Scripts that ran a step too late

The whole point of a pre-request script is that it can change a variable before the request uses it — generating a fresh auth token, say. My first version ran variable substitution first and the script after, which meant a script calling pm.environment.set("token", …) was updating a value that had already been baked into the request. The script ran, the console even logged the new token — it just never made it into the actual request. Flipping the order fixed it: load variables → run the pre-request script → substitute {{VAR}} using the (now updated) values → persist anything the script changed back to SQLite.

Why it's actually fast

Beyond the Tauri-vs-Electron difference, the release build is tuned specifically for a small, fast binary rather than the fastest possible compile:

src-tauri/Cargo.tomltoml
[profile.release]
lto = true             # cross-crate inlining and dead-code elimination
codegen-units = 1     # one codegen unit = more optimization opportunity
opt-level = "z"       # optimize for binary size over raw speed
panic = "abort"       # no unwind tables to carry around
strip = true          # no debug symbols in the shipped binary

None of these are individually dramatic. Together, they're the difference between a binary that feels like it was compiled and one that feels like it was actually finished.

If you want to build your own

You don't need my exact stack, but if you're building the same kind of tool — a fast, native, do-one-thing-well developer app — this is roughly the checklist I'd hand past-me:

App shell

Tauri 2 (Rust)

A native binary instead of shipping Chromium. The OS's own WebView renders the UI, so the app starts in under 2 seconds and idles under 80MB — that's not achievable with Electron.

UI layer

React + TypeScript + Vite

Nothing exotic. Vite gives instant HMR against the Tauri dev server, TypeScript keeps the IPC payloads honest on both sides of the bridge.

State

Zustand, one store per feature

socketStore, sseStore, environmentStore, tabStore… small independent stores instead of one giant global one. A WebSocket reconnect doesn't re-render your request tabs.

HTTP client

reqwest + rustls

rustls means no OpenSSL system dependency to fight with across macOS / Windows / Linux builds. hyper underneath for HTTP/1.1 and HTTP/2.

Storage

SQLite via rusqlite + r2d2

A pooled connection (not a single Mutex<Connection>) so a history search doesn't block a request that's mid-flight. WAL mode lets reads and writes coexist.

Scripting

rquickjs (embedded QuickJS)

Pre-request / test scripts need a real JS engine, but bundling Node.js just for that is a lot of dead weight. QuickJS compiles into the binary and starts in microseconds.

Realtime

tokio-tungstenite + SSE + axum

WebSocket and SSE connections are long-lived, so they don't fit the request/response invoke() model — they push events with app.emit() instead. axum runs the embedded mock server.

Editor

Monaco

The same editor VS Code uses, for body/script editing with real syntax highlighting instead of a plain textarea.

The actual roadmap, in the order I'd do it again: get one raw request/response round-trip working end to end (even with two inputs and a button — seriously, ship that first). Then add a proper request builder with tabs. Then persistence, so closing the app doesn't lose your work. Then environments and variable substitution. Everything after that — scripting, WebSocket/SSE, load testing, OpenAPI import — is additive; none of it needs to exist for the core loop to be useful, and building it in that order means you have something real to use (and get annoyed at) the whole way through.

Anyway

That's the honest version. Not "I identified a market gap in the API tooling space" — I was avoiding a tutorial and ended up building the thing you're using instead. If any part of the architecture above is useful to you, steal it. If you build your own version, I'd genuinely like to see it.

Thanks for reading this far.

Try Kairo