HTTP history
mnml appends every completed HTTP send to two files: <workspace>/.rqst/history.jsonl (per-workspace) and ~/.config/mnml/history-global.jsonl (cross-workspace, with a workspace field added). One JSON line per request, OK or error, status or transport failure. The workspace log is the day-to-day forensic surface; the global log is what :http.history_global reads when you remember a request but not which project you were in.
The point: when you fired something interesting an hour ago and now you can’t remember the exact URL parameters, the answer is there. Same when a 401 from earlier wasn’t actually transient and you want to re-run it post-fix. And same when “earlier” was last week, in a different repo.
The file
Section titled “The file”<workspace>/.rqst/history.jsonlOne line per request, JSON-encoded, appended by drain_http_jobs after every http.send completes (success or failure). The file is created on first append; missing directory is created automatically.
{"ts":1734652803123,"method":"POST","url":"https://api.example.com/users/42","status":401,"duration_ms":142,"body_bytes":98,"error":null,"headers":[["Authorization","Bearer eyJ..."],["Content-Type","application/json"]],"request_body":"{\"name\":\"Alice\"}"}{"ts":1734652811876,"method":"GET","url":"https://api.example.com/users","status":200,"duration_ms":56,"body_bytes":2148,"error":null,"headers":[["Authorization","Bearer eyJ..."]],"request_body":null}{"ts":1734652815001,"method":"GET","url":"https://broken-host.example.com/","status":null,"duration_ms":null,"body_bytes":null,"error":"connection failed: dns error","headers":null,"request_body":null}Schema:
| Field | Type | When null |
|---|---|---|
ts | u64 millis | always present |
method | string | always present |
url | string | always present |
status | u16 / null | null when the request errored before the response landed (transport / DNS / TLS) |
duration_ms | u128 / null | null when the request errored before timing was meaningful |
body_bytes | usize / null | null on error; size of the response body otherwise |
error | string / null | one-line transport error message when present; null on success |
headers | [[name, value], …] / null | the request headers that fired (after env expansion). Added 2026-06-28 so re-fires from history reconstruct a complete curl |
request_body | string / null | the request body that fired (utf-8). Null on bodyless requests |
status: null with error: "…" is the transport-failure shape. status: 401 with error: null is “the request completed, the server said 401” — a successful send to the HTTP layer, regardless of what the status means semantically.
Appends are append-mode open + write. POSIX guarantees atomic appends for writes under PIPE_BUF (4 KB on Linux/macOS); every entry mnml writes is well under that, so concurrent writers (two mnml instances in the same workspace) interleave at line boundaries without corruption.
The log grows forever — there’s no rotation. For most workspaces this is fine; a few hundred entries a day is a few hundred KB a month. If you want to trim, truncate history.jsonl from the shell — mnml will start appending again on the next send.
Forensic queries from the shell
Section titled “Forensic queries from the shell”The file is intentionally JSONL so it’s grep-able and jq-able without leaving the terminal:
# Every 401 today.grep '"status":401' .rqst/history.jsonl | jq -c '{ts, url}'
# Anything slower than 1 second.jq -c 'select(.duration_ms > 1000)' .rqst/history.jsonl
# Every failed send.jq -c 'select(.error != null) | {ts, url, error}' .rqst/history.jsonl
# Histogram of status codes from the last 50 sends.tail -50 .rqst/history.jsonl | jq '.status' | sort | uniq -c | sort -rn
# Find the 5 slowest requests this week.jq -c 'select(.ts > (now - 86400 * 7) * 1000) | {url, duration_ms}' .rqst/history.jsonl \ | sort -t: -k3 -n -r | head -5The ts field is milliseconds since Unix epoch — (.ts / 1000) | strftime("%Y-%m-%d %H:%M") formats it in jq if you want human-readable dates.
The picker — :http.history
Section titled “The picker — :http.history”In the editor:
| Surface | Call |
|---|---|
| Palette | HTTP: open .rqst/history.jsonl (one-line-per-send log) |
| Ex-command | :http.history |
No default keybinding. Bind it under [keys.global] or call it from the palette.
The picker loads the last 100 entries (newest first) and renders them as fuzzy-pickable rows:
HTTP history ▸ GET api.example.com/users 200 · 56ms POST api.example.com/users/42 401 · 142ms GET broken-host.example.com/ FAILED · - PUT api.example.com/orders/abc 204 · 89ms GET api.example.com/users?role=admin 200 · 412msThe display format:
- Method (right-padded to 6 chars).
- Short URL — host + path, scheme stripped, query string stripped.
- Status · duration —
200 · 56mson success,FAILED · -when both status and duration are null,200alone when duration is missing,FAILED · 142mswhen only the duration is present.
Type to filter — the fuzzy match runs over method + short URL + the status detail. So 401 narrows to authentication failures; users/42 narrows to that endpoint.
Re-firing — Enter
Section titled “Re-firing — Enter”Pressing Enter on a row opens a fresh scratch .curl buffer with the chosen request rendered as a curl command. The buffer is unsaved and unnamed — perfect for tweaking the URL or headers before :http.send-ing it again. You can also Ctrl+S to save it under a new path if it’s worth keeping.
For entries logged 2026-06-28 or later, the scratch is a complete curl: -X <method>, every persisted -H 'Name: Value', the --data-raw '<body>' (when the request had one), and the URL. Quoting follows curl’s single-quoted form with '\'' for embedded apostrophes. Older entries (no headers / request_body fields) still re-fire — they just render as a minimal curl -X GET '<url>' and you fill in headers by hand.
The re-fire happens at the source-buffer level, not as a direct http.send — that’s deliberate. The history log records the URL and headers that were actually fired, with {{VAR}} already substituted; the scratch buffer holds the resolved form. If the original used {{TOKEN}}, re-firing from history doesn’t re-resolve against the current env — it uses the same baked-in token the original send did. To restore env-driven behavior, edit the buffer to re-introduce the {{TOKEN}} placeholder before sending.
The 100-entry cap on the picker is a UI choice, not a data limit — the file still holds every entry. For older entries, grep / jq directly.
Cross-workspace recall — :http.history_global
Section titled “Cross-workspace recall — :http.history_global”Workspace-scoped history is the common case, but there’s a second log: every send also mirrors into ~/.config/mnml/history-global.jsonl with a workspace field added. When you remember firing a specific request a week ago but not which project you were in, that’s what :http.history_global is for.
| Surface | Call |
|---|---|
| Palette | HTTP: history picker across all workspaces (~/.config/mnml/history-global.jsonl) |
| Ex-command | :http.history_global |
The global file
Section titled “The global file”~/.config/mnml/history-global.jsonlSame JSONL shape as the workspace log, with two extra fields:
{"ts":1734652803123,"workspace":"acme-api","workspace_path":"/Users/me/code/acme-api","method":"POST","url":"https://api.example.com/users/42","status":401,"duration_ms":142,"body_bytes":98,"error":null}| Field | Type | Notes |
|---|---|---|
workspace | string | The workspace directory’s file_name() — short identifier. ? if the path has no basename. |
workspace_path | string | The full absolute path to the workspace, for unambiguous re-identification when two workspaces share a basename. |
Every other field matches the workspace log line-for-line — ts, method, url, status, duration_ms, body_bytes, error. So the same shell queries work, just against a different file:
# Every 401 across all workspaces today.grep '"status":401' ~/.config/mnml/history-global.jsonl | jq -c '{ts, workspace, url}'
# Find every request you ever fired to a specific host.jq -c 'select(.url | contains("api.staging.example.com"))' \ ~/.config/mnml/history-global.jsonlBest-effort append: if $HOME is unset, the parent directory can’t be created, or the file can’t be opened, the workspace log still lands — the global mirror just silently drops. The workspace log is canonical; the global file is opportunistic.
Test override
Section titled “Test override”The path is overridden by $MNML_HISTORY_GLOBAL_PATH when set — primarily so the test suite can write into a tempdir without polluting the real user log. You can set it yourself if you want a different cross-project log location, but the standard XDG-style default is what :http.history_global reads by default.
The picker
Section titled “The picker”Same shape as :http.history, with one difference — the detail line carries the workspace identifier:
HTTP history · all workspaces ▸ GET api.example.com/users acme-api · 200 · 56ms POST api.example.com/users/42 acme-api · 401 · 142ms GET api.staging.com/health beta-svc · 200 · 8ms PUT api.example.com/orders/abc acme-api · 204 · 89ms GET broken-host.example.com/ acme-api · FAILED · -The detail format follows the (status, duration) shape from :http.history, prefixed with the workspace label:
<workspace> · 200 · 56ms— success.<workspace> · 200— completed but duration missing (rare).<workspace> · FAILED · 142ms— transport error with timing.<workspace> · FAILED— transport error before timing was meaningful.
Type to filter — the fuzzy match runs across method + short URL + the workspace-prefixed detail. So acme-api narrows to one project; beta-svc 200 narrows to that project’s successes.
Re-firing
Section titled “Re-firing”Enter on a row opens a .curl scratch in the current workspace — the same path :http.history takes. The re-fire happens here, not in the workspace where the original send fired, which is what you usually want (you’ve moved on to a different project and want to poke at this request).
If the original send used {{TOKEN}}, the scratch carries the resolved value the original send used. To re-resolve against the current workspace’s env, edit the buffer to re-introduce the {{TOKEN}} placeholder before sending — same caveat as in the workspace picker.
When entries land
Section titled “When entries land”A history entry is written from App::drain_http_jobs after a background HTTP worker finishes:
- Success path (
Ok(ResponseView)) —status: Some(status_code),duration_ms: Some(elapsed.as_millis()),body_bytes: Some(body.len()),error: None. The pane flips toRunState::Doneand the entry lands. - Failure path (
Err(transport_error)) —status: None,duration_ms: None,body_bytes: None,error: Some(message). The pane flips toRunState::Failedand the entry still lands — failed sends matter for forensics.
What’s not logged:
http.benchruns. A 10-shot bench would write 10 entries per call and bloat the file. Bench results go to the clipboard instead — see HTTP bench.http.replay_mockcalls. Replay flips the pane state without firing; nothing crossed the network, nothing gets logged.http.lookupbackground fires. The lookup flow’s “stage 2” background send is also unlogged — it’s an internal request used to populate the item picker, not a user-fired send.- Browser pane network entries. CDP-captured requests are browsing, not sending — they go to
.rqst/captured/log.jsonl, the captured browser log. See HTTP captured.
Where history fits
Section titled “Where history fits”History is the “what did I send and when?” log. Four sibling concepts:
| Concept | File | Records what |
|---|---|---|
| History (workspace) | <ws>/.rqst/history.jsonl | Every http.send from this workspace |
| History (global) | ~/.config/mnml/history-global.jsonl | Every http.send from any workspace, tagged with the source workspace |
| Captured | <ws>/.rqst/captured/log.jsonl | Every Network.requestWillBeSent the browser pane observed |
| Mocks | <source>.curl.mock.json | One frozen response per request file |
They don’t fight — they’re observational logs at three different layers. The same URL can show up in history (because you http.send-d it) and in captured (because you also opened the browser pane and the page loaded it).
- HTTP client — the parent overview: how a send produces the entry that lands here
- HTTP bench — explicitly not logged here; bench output is clipboard-only
- HTTP captured browser traffic — the browser-pane equivalent: CDP-observed requests in their own JSONL
- HTTP envs & templating — the substitutions that ran before the request was logged