Skip to content

Internal REST API

When Probe starts, it spins up an internal HTTP server on 127.0.0.1:<random> so probe-mcp and other local processes can read traffic, manage rules, and replay requests. The same API is documented here in case you want to script against Probe from a shell, a CI job, or your own tooling.

The server is implemented with axum and lives next to the proxy in the Rust crate.

Probe writes the listening port and a freshly-generated bearer token to:

~/.probe/api-handshake.json (macOS)
%USERPROFILE%\.probe\api-handshake.json (Windows)

Shape:

{
"port": 51827,
"token": "f3b1...c2a9",
"pid": 73114,
"version": "0.1.0",
"started_at": 1731081822
}
  • The file is written with mode 0600 on Unix.
  • A fresh port and token are generated each time the proxy starts — restarting the proxy or Probe invalidates the previous ones.
  • The file is removed when Probe quits cleanly.
  • When the PROBE_DATA_DIR environment variable is set, the data folder (and this file) is <PROBE_DATA_DIR>/api-handshake.json instead.

Read it, point your client at http://127.0.0.1:<port>/api/v1/..., and send the token in the Authorization header. If a call can't connect or answers 401, read the file again: the proxy has restarted with a new port and token. probe-mcp does exactly that and retries once.

With the Probe app running, a write to state the app owns is handed to the app, which applies it through its own code and shows it in its UI — the same as if you had made the change there. Rules, master switches, breakpoints and the ignored lists are saved; the global throttle, the scripting switch and the Mock server's running state reset on restart, as they do in the app. That covers Map Local, Map Remote and Mock rules and their master switches, POST /mock/start and /mock/stop, script rules and the scripting switch, breakpoints, ignored hosts and clients, and the global throttle. The route answers only after the app confirms, and such writes run one at a time, so parallel calls queue. If the app rejects the change, you get its status and error; if it doesn't answer within 15 seconds, you get 504 with {"error": "...", "applied_in_proxy": true} — the proxy is already using the change, but it may not be saved. Until the app answers that change (at most 2 minutes), further writes to state the app owns get 503 with {"error": "...", "applied_in_proxy": false} and change nothing: the app could still apply the earlier change late, which would undo them.

Without the app UI (a headless run such as the smoke test), these writes stay in the proxy's memory until it stops. Intercept hosts (the SSL-proxying allowlist) are never handed to the app; they last for the current run.

The session routes work the other way round: the app answers them from its live tabs and saved sessions.

Every endpoint except /api/v1/health requires:

Authorization: Bearer <token>

The scheme name is case-insensitive (bearer works, RFC 7235); the token must match exactly.

Without the header (or with the wrong token), the API returns 401 Unauthorized. Before a token is set up, every call except /api/v1/health is a 401 too (the API never runs open). The token is compared in constant time.

Quick check:

Terminal window
PORT=$(jq -r .port ~/.probe/api-handshake.json)
TOKEN=$(jq -r .token ~/.probe/api-handshake.json)
curl -s "http://127.0.0.1:$PORT/api/v1/health"
# {"status":"ok","version":"0.1.0","capturing":true,"entry_count":42, ...}

Every non-2xx answer is JSON with three top-level fields, whichever route (or the server itself) produced it:

{
"error": "not found",
"code": "notFound",
"hint": "list ids with GET /api/v1/traffic; ids restart at 1 when Probe restarts and old rows are evicted"
}
  • error is a human-readable string. The Files routes keep their {"code", "message"} object here and also carry the top-level code and hint.
  • code is a stable camelCase value to branch on.
  • hint is one sentence naming the next step.

Routes may add fields next to these (for example applied_in_proxy). Status codes are unchanged, and successful (2xx) bodies never get these fields added.

StatusDefault code
400badRequest (routes may use a finer code such as invalidArgument)
401unauthorized
403forbidden
404notFound
405methodNotAllowed
409conflict
413payloadTooLarge
415unsupportedMediaType
422invalidBody
500internal
502upstreamError
503unavailable (uiNotRunning when the route needs the Probe app)
504timeout

Routes that can say more use a finer code with the same status family:

StatuscodeMeaning
400invalidArgumentA parameter or body field is missing or has a bad value; hint names it.
400invalidAssertionPOST /traffic/assert: unknown target, operator or quantifier, or no assertions; hint lists the valid choices.
404runNotFoundThe test_run_id was never begun, was deleted, or ended more than an hour ago.
404runFromPreviousInstanceThe run id belongs to an earlier Probe process; its requests are gone.
404ruleNotFoundThe test run has no rule with that id.
404unknownRunGET /flow-runs/{run_id}: no timed-out flow run with that id (only runs that outlived the 120 s wait are kept).
409tooManyRunsThe maximum number of test runs exists already; delete finished ones.
409tooManyRulesThe run holds the maximum number of test rules.
409duplicateRuleIdThe run already has a rule with that id.
409runEndedThe run has ended and its rules were removed; begin a new one.
409runScopeRequiredThe run has no scope (header, client or host), so a test rule would apply to all traffic; begin the run with a scope.
409captureOffGET /traffic/wait: capture is off or the proxy is stopped, so nothing new can arrive.
409runExpiredGET /traffic/wait: the test run's lease ran out during the wait; begin a new run.
429tooManyWaitersGET /traffic/wait: 32 waits are open already; retry when one returns.
409installInProgressPOST /ca/install: an install is already waiting for the person to answer the OS prompt; wait, then read trusted with GET /ca.
409childWindowsOpenPOST /ca/install (macOS): other Probe windows are open, which the administrator prompt would freeze; nothing was installed or closed. windows lists them; ask the person to close them, then retry.
503windowNotOpenPOST /ca/install: the app runs but its main window, which shows the prompt and the result, is not open.
503caNotLoadedGET /ca: the proxy has not loaded its CA in this Probe process yet; start the proxy and retry.
409collectionChangedCompose environment write: its secret values changed while being stored; nothing was written, read it again and retry.
403auth, permissionDeniedFiles: the remote server refused the credentials, or the account may not do that.
404notFoundFiles: the remote path does not exist.
409alreadyExists, cancelledFiles: the target exists already, or the transfer was cancelled already.
400unsupported, protocolFiles: the backend cannot do that, or the server broke the protocol.
500ioFiles: a local or remote I/O failure.
502hostUnreachable, hostKeyRejectedFiles: the remote host cannot be reached, or its host key was rejected.
503uiNotRunningThe route needs the Probe app (Composer sends, flow runs, app-owned writes).
504timeoutThe app did not answer in time.

Returns the OpenAPI 3.1 description of this API: every path and method, path and query parameters, request bodies, and the shared Error schema. It needs the bearer token like every route except health.

Public — no auth required. Useful for liveness checks.

{
"status": "ok",
"version": "0.1.0",
"capturing": true,
"entry_count": 142,
"uptime_secs": 3621,
"api_port": 51827,
"instance_id": "3f9a1c0b7d42"
}

instance_id is random per Probe process; test-run ids contain it.

version is the version of Probe's Rust core, not the app's release number. The handshake file's version field reports the same value.

List captured entries with optional filters.

Query paramTypeDescription
limitintDefault 50, capped at 500.
offsetintDefault 0.
hoststringSubstring match against host.
methodstringExact match.
statusintExact status code (sets both min and max).
status_min / status_maxintStatus range.
pathstringURL path filter.
searchstringSubstring search across URL and body.
has_errorbooltrue for status ≥ 400 only.
since_idintOnly entries with id > since_id.
sinceintOnly entries with timestamp >= since (ms since epoch).
test_run_idstringOnly the requests of this test run; the answer gains a test_run object with the run and its stats. Unknown run: 404 runNotFound.
clientstringSubstring of the client label (client_addr: process name for local traffic, IP for a phone), case-insensitive.
headerstringRequest header: Name:value (exact value) or Name (present). Name is case-insensitive.
Terminal window
curl -s "http://127.0.0.1:$PORT/api/v1/traffic?host=api.example.com&limit=10" \
-H "Authorization: Bearer $TOKEN"

Response:

{
"items": [
{
"id": 142,
"method": "POST",
"url": "https://api.example.com/v1/users",
"host": "api.example.com",
"status_code": 201,
"content_type": "application/json",
"duration_ms": 184,
"timestamp": 1731081900123,
"client_addr": "Chrome",
"has_response": true
}
],
"total": 142,
"offset": 0,
"limit": 10
}

Long-poll: answers as soon as enough matching traffic exists, instead of you polling /traffic. It takes the /traffic filters (host, method, status, status_min, status_max, path, search, has_error, include_connect, client, header, test_run_id) and:

Query paramTypeDescription
phasestringresponse (default: the response has arrived) or request (the request was stored).
cursorintA cursor from a previous answer: only events after it count. Absent: 0 when test_run_id is given (the run's existing traffic counts), otherwise "only traffic from now on".
min_countintRows needed, default 1, at most 100.
timeout_msintLongest wait, default 30000, at most 60000.

Matched (200): { "matched": true, "items": [...], "cursor": 18 }. items are traffic summaries, oldest event first, at most 100; pass cursor back to continue. Not matched in time (200, never a 408): { "matched": false, "cursor": 18, "waited_ms": 30000 } with the same cursor you sent, plus capture_note when capture is off and decryption_hint when a host filter names a host Probe does not decrypt. When the proxy is stopped or capture is off and nothing matching is already there, it answers 409 captureOff at once (also if capture stops during the wait). With test_run_id, the wait renews the run's lease while it is open, never returns requests that started after the run ended, answers 409 runExpired if the run's lease ran out during the wait, and the run's own 404 if it was deleted. At most 32 waits may be open at once; one more answers 429 tooManyWaiters (let one return, then retry).

The cursor is a sequence number of the store, not a request id: ids are taken when a request starts, so a slow request with a small id can be stored after a larger one, and a plain since_id would skip it.

Full detail of one entry — including request/response headers and bodies. A 502 row with an x-probe-upstream-error header means Probe could not reach the server: that header and the error-text body are Probe's own diagnostics, not wire data (the client received an empty 502).

{
"id": 142,
"method": "POST",
"url": "https://api.example.com/v1/users",
"host": "api.example.com",
"status_code": 201,
"timestamp": 1731081900123,
"req_headers": [["content-type", "application/json"], ["authorization", "Bearer ..."]],
"req_body": "{\"name\":\"Ada\"}",
"res_headers": [["content-type", "application/json"]],
"res_body": "{\"id\":1,\"name\":\"Ada\"}",
"content_type": "application/json",
"client_addr": "Chrome",
"duration_ms": 184,
"has_response": true
}

404 if the id doesn't exist.

Drop every entry from the copy this API reads and, while the Probe app UI is connected, clear its log list too (as its Clear button does). Returns 204 No Content.

Drop a single entry. 204 on success, 404 if missing.

Plain text — a runnable cURL command. Content-Type: text/plain. The body is written with --data-raw, so a body starting with @ never makes cURL read a local file.

Plain text — raw HTTP request and response.

The captured traffic as a HAR 1.2 document (application/json, sent as an attachment named probe-<run id or timestamp>.har), oldest request first. Accepts the /traffic filters (host, method, status, status_min, status_max, path, search, has_error, since_id, since, client, header) plus test_run_id, max_body_bytes (per body, default 262144) and include_connect (CONNECT tunnel rows, default false).

log.creator.version is Probe's core library version, not the app version. A body Probe did not keep (binary, too large, never buffered) is never written into text: the entry's _probe.bodyUnavailable.request / .response says why (binary, tooLarge, notBuffered), and _probe.truncated says a body was cut. A request without a response has status: 0 and _probe.pending: true. The export stops at 50 MB and says so in log._probe (truncatedExport, omitted). An unknown test_run_id answers 404 runNotFound.

Check captured traffic against assertions. A failing assertion is still 200: read passed.

{
"test_run_id": "tr-...",
"filter": { "host": "api.example.com" },
"assertions": [
{ "target": "count", "op": "equals", "value": 3 },
{ "target": "status", "op": "lessThan", "value": 400, "quantifier": "all" },
{ "target": "body:data.items[0].id", "op": "exists" },
{ "target": "order", "value": [{ "method": "POST", "path": "/login" }, { "method": "GET", "path": "/me" }] }
]
}

test_run_id and filter (the /traffic filter names) are optional. Targets: count, status, method, url, path, host, duration_ms, req_header:<name>, header:<name> (response), req_body[:<dotted.path>], body[:<dotted.path>] (an empty path is the whole body) and order (value is a list of {method?, path?, host?} that must appear in that order by request id). Operators are the Flow ones: equals, notEquals, contains, lessThan, greaterThan, exists, matches (a regular expression without lookaround or backreferences; an invalid one never matches; \d, \w and \s also match non-ASCII digits, letters and spaces, unlike in Flow assertions, so write [0-9] when it matters). quantifier is all (default), any or none. CONNECT rows are ignored.

Returns: {passed, matched_count, results, test_run?} (test_run with its stats when test_run_id is given). Each result has index, passed, target, op, quantifier, expected, actual (at most 200 characters), entry_ids (at most 20) and reason (null when passed): mismatch, noEntries, bodyUnavailable (the body was binary, too large, cut or never buffered: it is never compared), hostNotDecrypted (nothing matched and Probe does not decrypt that host), invalidRegex or invalidPath. An unknown target, operator or quantifier answers 400 invalidAssertion with the valid choices in hint.

Re-send a captured request, optionally edited. The JSON body is optional; {} or no body replays the request as captured.

{
"method": "POST",
"url": "https://staging.example.com/v1/users",
"headers": { "authorization": "Bearer new-token", "x-debug": null },
"body": "{\"name\":\"Ada\"}",
"through_proxy": true
}
FieldTypeNotes
methodstring?Override the method.
urlstring?Override the full URL.
headersobjectEdits by header name, case-insensitive: a string sets or replaces, null removes. Other captured headers are kept.
bodystring?Replace the request body.
through_proxyboolDefault true: send through Probe's proxy, so the replay is captured and Map Local, Map Remote, breakpoints and scripts apply. false sends straight to the origin.

The captured accept-encoding header (so the body comes back readable), content-length and hop-by-hop headers are not copied.

{ "status_code": 201, "headers": [["content-type", "application/json"]], "body": "{\"id\":2}", "body_truncated": false, "duration_ms": 143, "new_id": 143, "through_proxy": true }

At most 2 MiB of the response body is read: a longer body is cut there, ends in …[truncated: replay response cut at 2097152 bytes], and body_truncated is true. The whole response is in the captured entry new_id when the replay went through the proxy.

new_id is the traffic id of the captured replay, or null when it went direct or wasn't captured within half a second. 404 if the entry doesn't exist, 400 for an invalid method, 502 if the send failed or took longer than 60 seconds — each with {"error": "..."}.

Compare two entries. Returns a structured diff over method, URL, status, headers, and body.

{ "capturing": true, "entry_count": 142, "proxy_running": true, "app_connected": true }

proxy_running false means nothing new arrives even while capturing is true; the API itself runs whenever the app is open. app_connected says whether capture start/stop and DELETE /api/v1/traffic also act on the app.

Enable recording. With the app connected this is the app's own Start (the proxy starts, as with its toolbar button). Returns 204.

Disable recording. With the app connected this is the app's own Stop (the proxy stops and the system proxy is turned off if Probe turned it on); without it, traffic still proxies but isn't stored. Returns 204.

Probe's root CA for agents and scripts. The matching MCP tools are probe_get_ca and probe_install_ca.

Works without the app UI. 503 caNotLoaded until the proxy has started once in this Probe process.

{
"pem": "-----BEGIN CERTIFICATE-----\n…",
"sha256": "3F:A1:…",
"spki_sha256": "Xj1m…=",
"proxy": "127.0.0.1:9099",
"proxy_running": true,
"downloads": {
"pem": "http://127.0.0.1:9098/download/cert.pem",
"der": "http://127.0.0.1:9098/download/cert.der",
"android": "http://127.0.0.1:9098/download/android",
"ios": "http://127.0.0.1:9098/download/ios",
"macos": "http://127.0.0.1:9098/download/macos"
},
"trusted": false,
"trusted_age_s": 12,
"hints": ["Node: NODE_EXTRA_CA_CERTS=probe-ca.pem", "…"]
}

pem is the certificate the proxy signs with, from memory: the same bytes the certificate server serves. spki_sha256 is the base64 SHA-256 of its public key, for Chromium's --ignore-certificate-errors-spki-list. trusted is the app's last trust check and trusted_age_s how many seconds ago it ran (read from the app's memory, no new check per request), or "unknown" without the app or when it doesn't answer within 2 s. hints has one line per client (Node, Python requests and OpenSSL, curl, Chromium, the iOS simulator, the Android emulator, this computer). While the proxy is stopped a note says the download URLs don't answer.

No body. Asks the running app to install and trust the CA on this Mac or PC through its own Install button: a person must approve the macOS administrator prompt or the Windows security warning on screen. Right before it the app shows a notice that an AI agent asked. On macOS, while any other Probe window is open, nothing is closed or installed: 409 childWindowsOpen with windows (the open window kinds), and the app asks the person to close them. The route takes no credential and never bypasses the prompt. 200 with {outcome, trusted, reason?}: outcome is installed, already_trusted (no prompt shown), cancelled or failed (reason: osError, trustNotVerified or unsupportedPlatform; the error text stays in the app's dialog). One install at a time: a second call while one waits is 409 installInProgress. 503 uiNotRunning without the app, 503 windowNotOpen without its main window (trust the CA per process with the hints of GET /api/v1/ca); 504 timeout after 120 s, when the prompt may still be open.

Array of {pattern, intercept_request, intercept_response}.

Create or update a breakpoint. Pattern goes in the body so URL-shaped patterns (with slashes) don't need re-encoding. A pattern that can never match (empty, a bare path, a host-only pattern with a port or wildcard, or no host) is a 400 with a hint.

{ "pattern": "api.example.com/users/*", "intercept_request": true, "intercept_response": true }

204 No Content.

Delete a single breakpoint by pattern.

{ "pattern": "api.example.com/users/*" }

204, or 404 if no breakpoint has that pattern.

Drop every breakpoint. 204.

A breakpoint match parks the request or response until you resume it (or for 5 minutes, after which it continues unchanged). With the app running, a paused response whose request is no longer in an open session tab that isn't saved continues unchanged at once.

One entry per paused message, sorted by id:

[ { "id": 142, "phase": "request", "method": "POST", "url": "https://api.example.com/v1/users", "status_code": null } ]

phase is request (url, headers and body can be edited), response (status_code, headers and body), or unknown when this API's traffic capture is off. status_code is set for responses.

Release a paused request or response, optionally rewriting it.

{
"abort": false,
"status_code": 200,
"headers": [["x-debug", "1"]],
"body": "{\"forced\":true}",
"url": "https://staging.example.com/v1/users"
}

headers replaces the whole header list. Leave it out to keep the captured headers — a body-, URL- or status-only edit then keeps Host, Authorization and cookies. If this API's capture is off the captured headers are unknown, and such an edit is a 400 until you send headers.

204 on success, 404 if the id no longer exists.

Array of host strings.

Traffic to an ignored host is tunnelled without decryption or logging. Hosts are matched case-insensitively.

Add a host. 204.

Remove a host. 204, or 404 if it isn't in the list.

Clear the list. 204.

Same shape as ignored hosts, but the key is a client label — process name (loopback) or IP (remote phone).

  • GET /api/v1/ignored-clients
  • PUT /api/v1/ignored-clients/{label}
  • DELETE /api/v1/ignored-clients/{label}

The label segment is URL-encoded; the server decodes it. Labels match case-insensitively, so DELETE …/slack removes Slack. Removing a label that isn't in the list is a 404.

The SSL-proxying allowlist — these routes don't pause anything. While the list is empty, every HTTPS host is decrypted; once it has an entry, only listed hosts and their subdomains are decrypted and logged with bodies, and every other HTTPS host is tunnelled. The list lives in the proxy for the current run only; the app keeps no copy.

  • GET /api/v1/intercept-hosts
  • PUT /api/v1/intercept-hosts/{host} — {host} is a bare hostname or IP (subdomains included). A scheme, port, *. wildcard or path can never match, so it is a 400 {"error"} naming the bare host to send; nothing is stored.
  • DELETE /api/v1/intercept-hosts/{host} — 404 if the host isn't in the list.

The whole global throttle profile.

{ "enabled": true, "bytes_per_second": 102400, "up_bytes_per_second": 0, "down_delay_ms": 200, "up_delay_ms": 0, "dns_delay_ms": 0, "down_loss_pct": 0, "up_loss_pct": 0 }

bytes_per_second is the download cap. enabled is true when any value is non-zero.

Set the global download cap. Upload, latency, DNS delay and packet loss keep their values; in the app the toolbar's throttle icon shows throttling is on, and no preset in its menu is ticked. 204.

{ "bytes_per_second": 102400 }

Turn off the whole global profile: bandwidth both ways, latency, DNS delay and packet loss. Scoped rules are not affected. 204.

List the scoped throttle rules in list order (first enabled match wins): [{id, name, enabled, matcher: {type, pattern}, profile: {down_bps, up_bps, down_delay_ms, up_delay_ms, dns_delay_ms, down_loss_pct, up_loss_pct}}]. With the app running it answers with every rule, disabled ones too; without it you get the proxy's copy, the enabled rules only.

Add a rule: {name, matcher: {type: "host"|"urlGlob"|"app", pattern}, profile?: {…}, enabled?}. The app checks it, saves it and pushes the list to the proxy, so this needs the app running (503 with code uiNotRunning otherwise). A pattern that can never match is a 400 naming the form that would (a host pattern is a bare host: no scheme, port or path). 201 with the stored rule.

Change only the fields sent; matcher and profile keys merge one by one. 200 with the stored rule, 404 for an unknown id.

204, or 404 for an unknown id. Needs the app running.

With the app running, these routes are answered by the app from its live tabs and from your saved sessions (the local database ~/.probe/probe.db). Keys are snake_case; entries use the app's log-entry JSON.

The open session tabs. hidden_by_domain_watch counts requests a tab never captured because their root domain is not watched (Probe keeps only the first request per unwatched root).

{
"tabs": [
{ "id": "tab_1", "name": "Recording", "entry_count": 42, "is_recording": true, "is_dirty": true, "is_saved": false, "created_at": 1731081822000, "hidden_by_domain_watch": 0 }
],
"active_tab_id": "tab_1",
"recording_tab_id": "tab_1"
}

GET /api/v1/session-tabs/{id}?offset=0&limit=50

Section titled “GET /api/v1/session-tabs/{id}?offset=0&limit=50”

One page of a tab's entries.

{ "id": "tab_1", "name": "Recording", "created_at": 1731081822000, "entry_count": 42, "offset": 0, "limit": 50, "entries": [ … ] }
ParamDefaultNotes
offset0Entries to skip.
limit50Capped at 500.

Entries use snake_case keys like the traffic routes (status_code, client_addr, req_headers, req_body, res_headers, res_body, content_type, duration_ms, req_size, res_size, request_end_ts, response_start_ts, http_version). The page also carries hidden_by_domain_watch; when it is above 0 it adds hidden_domains ([{domain, count}], top 20) and a hint: watch the domain in the app (right-click the domain, then Watch, or View, then Watched Domains). Requests already dropped are not recovered.

404 if the tab doesn't exist.

Your saved sessions, newest first: {"sessions": [{id, name, entry_count, created_at}]}.

Save a live tab's capture as a saved session (the app's Cmd+S save; the tabs are left as they are): {name, tab_id?, host?, ids?} — the recording tab by default, host keeps that host and its subdomains, ids keeps those entries (at most 10,000). 201 with {id, name, entry_count, created_at, source_tab_id, missing_ids?} (with missing_reason, and hidden_by_domain_watch/hidden_domains/hint when the Domain Watch teaser dropped requests from the tab); 400 when nothing matches; 503 uiNotRunning without the app.

GET /api/v1/session-tabs/saved/{id}?offset=0&limit=50

Section titled “GET /api/v1/session-tabs/saved/{id}?offset=0&limit=50”

One page of a saved session's entries — same shape and paging as a tab, except each entry's id is a session-scoped string (<session>#<n>) with the capture-time log id in original_log_id_not_live: log ids restart with the app, so that number now names a different request in /api/v1/traffic. 404 if the session doesn't exist.

Collections are JSON files in ~/.probe/compose/; the app's file watcher reloads them after every write. The write routes bring requests into the shape the app loads: headers, queryParams and cookies may be an object {name: value}, a list of {key, value, enabled} or a list of [name, value] pairs; request keys may be camelCase or snake_case (query_params, content_type, graphql_variables, timeout_seconds, pre_request_script, test_script); a non-string body is stored as its JSON text. Any other shape is a 400 naming the field — the app would otherwise skip the whole collection without a word. A collection id ({id}) names a file, so it may contain only letters, digits, ., _ and -, and may not start with . or contain ..; any other collection id is a 404. Writes to one collection are serialized and written atomically, so parallel calls don't lose each other's changes.

Array of {id, name, request_count, folder_count, environment_count, created_at}.

Full collection JSON — folders, requests, environments, default headers, default auth.

Delete the collection's JSON file. 204. 404 if it doesn't exist. A collection that is still open in the app with unsaved edits may be written back by the app's next save, which brings it back.

Import a new collection through the app's importers: {format: "postman"|"curl"|"probe", content, name?} (content at most 1 MiB, else 413). 201 with {id, name, request_count}. Postman secret variables go to the OS keychain. Needs the app running (503 uiNotRunning).

Create a collection.

{
"name": "Acme API",
"requests": [ { "name": "List users", "method": "GET", "url": "{{base}}/users" } ],
"folders": [],
"environments": [
{ "name": "Dev", "variables": [{"key": "base", "defaultValue": "https://dev.acme.test"}] }
]
}

Also accepts default_headers (object or list) and default_auth (object or null). Returns 201 with {id, name, path} — the new collection's server-assigned id and its file path.

POST /api/v1/compose/collections/{id}/requests

Section titled “POST /api/v1/compose/collections/{id}/requests”

Add a request to a collection. Optional folder_id to nest it. The fields are top-level, in snake_case:

{
"name": "Create user",
"method": "POST",
"url": "{{base}}/users",
"headers": { "Content-Type": "application/json" },
"query_params": [],
"cookies": [],
"body": { "name": "Ada", "email": "ada@example.com" },
"content_type": "application/json",
"auth": null,
"timeout_seconds": 30,
"folder_id": null
}

Also accepts graphql_variables, description, pre_request_script and test_script. Defaults: method GET, content_type application/json, timeout_seconds 30. auth is null to inherit from the collection, or a typed object: {"type": "bearer", "token": "..."}, {"type": "basic", "username": "...", "password": "..."}, {"type": "apiKey", "key": "...", "value": "..."}. Returns 201 with {id, name} — the new request's server-assigned id. 404 if the collection or folder_id doesn't exist.

PUT /api/v1/compose/collections/{id}/requests/{rid}

Section titled “PUT /api/v1/compose/collections/{id}/requests/{rid}”

Partial update — only fields present in the body overwrite the stored request. The request may live at the collection root or inside any nested folder; the route searches both.

{ "name": "Create user (v2)", "timeout_seconds": 60 }

Accepts the same fields as POST .../requests (name, method, url, headers, query_params, cookies, body, content_type, auth, graphql_variables, timeout_seconds, pre_request_script, test_script, description). headers, query_params and cookies replace the whole list. "auth": null clears the request's auth so it inherits from the collection; leaving auth out keeps it. folder_id is not accepted here — it's a one-time placement instruction for creation, not a stored field; this route always updates the request in place wherever it currently lives.

400 if the body has no updatable field. 404 if the collection or request doesn't exist.

DELETE /api/v1/compose/collections/{id}/requests/{rid}

Section titled “DELETE /api/v1/compose/collections/{id}/requests/{rid}”

Remove a request from the collection root or any nested folder. 204. 404 if the collection or request doesn't exist.

POST /api/v1/compose/collections/{id}/environments

Section titled “POST /api/v1/compose/collections/{id}/environments”

Add an environment.

{
"name": "Dev",
"variables": [
{ "key": "base", "defaultValue": "https://dev.acme.test", "currentValue": null, "enabled": true, "isSecret": false },
{ "key": "token", "defaultValue": "", "currentValue": "abc123", "enabled": true, "isSecret": true }
]
}
FieldTypeNotes
keystringVariable name; referenced as {{key}} in URL/headers/body.
defaultValuestringPersisted value.
currentValuestring?Session-only override. Cleared on app restart.
enabledboolWhen false, the variable behaves as if undefined.
isSecretboolMasks the value in console output and the variable hover preview.
typestring?string (default), secret, number, boolean or json.

Any other member (e.g. a misspelled secret or is_secret), a non-boolean isSecret or an unknown type is refused with a 400 naming its path (environments[0]: Unknown member \variables[1].is_secret`…) — on create, add and update alike — so a secret is never stored in plain text by mistake. valueis accepted as a shorthand fordefaultValue`.

PUT /api/v1/compose/collections/{id}/environments/{eid}

Section titled “PUT /api/v1/compose/collections/{id}/environments/{eid}”

Update an environment's name and/or variables. eid may be the environment's id or its name (mirrors how /compose/send's environment param resolves). Only fields present in the body overwrite.

{
"variables": [
{ "key": "base", "defaultValue": "https://staging.acme.test", "currentValue": "", "enabled": true, "isSecret": false }
]
}

400 if the body has neither name nor variables. 404 if the collection or environment doesn't exist.

DELETE /api/v1/compose/collections/{id}/environments/{eid}

Section titled “DELETE /api/v1/compose/collections/{id}/environments/{eid}”

Delete an environment. eid may be the environment's id or its name. If it was activeEnvironmentId, the collection falls back to the first remaining environment (or clears the field if none remain). 204. 404 if the collection or environment doesn't exist.

POST /api/v1/compose/collections/{id}/folders

Section titled “POST /api/v1/compose/collections/{id}/folders”

Create a folder at the collection root, or nested under an existing folder.

{ "name": "Admin endpoints", "folder_id": null }

folder_id is optional — omit (or null) for the collection root, or an existing folder's id to nest under it. Returns {"id": "..."} with the new folder's server-assigned id. 404 if folder_id is given but doesn't resolve to an existing folder.

Send a request through Probe's real Composer send path — pre-request/test scripts run, auth and the cookie jar are applied, a GraphQL content type is wrapped in the {query, variables} envelope, and (when the proxy is running) the request is routed through it and lands in the traffic log. Bridges to the running app UI, so it needs Probe open. The request's timeout_seconds (timeoutSeconds on an inline request) must be 1–110: a value outside that range is a 400 naming the range, never clamped. auth.type is a closed set (none, bearer, basic, apikey); any other type, such as apiKey, is refused with a hint. The the whole send (scripts included) is stopped waiting for after 115 s: the response then has status_code 0 and an error, before the 120 s after which the bridge answers 504.

The scripts behave as they do in the app: a pre-request script's pro.request edits change what is sent (the saved request is left alone), and their variable writes are saved — environment and collection-variable writes to the environment named in environment, else the collection's active one; global writes to the globals. environment backs every {{key}} that variables doesn't set, and the explicit variables beat every environment and the globals (only a script's pro.variables.set locals rank higher). Scripts see the same order: pro.variables.get, replaceIn and toObject return a variables value over the environment's.

A collection_id or environment that doesn't exist is an error (nothing is sent): the result's error says unknown collection / unknown environment and lists the collection's environments. environment needs a collection (named, or the one owning request_id).

Body — either a saved request (collection_id + request_id):

{
"collection_id": "abc",
"request_id": "xyz",
"environment": "Dev",
"variables": { "userId": "42" }
}

or an ad-hoc, unsaved request (request object, ComposeRequest-shaped with camelCase keys):

{
"request": {
"method": "POST",
"url": "https://api.example.com/{{path}}",
"headers": [{ "key": "Content-Type", "value": "application/json", "enabled": true }],
"body": "{}"
},
"variables": { "path": "users" }
}

collection_id may be combined with an inline request (and no request_id) to borrow that collection's auth/cookie/variable context for an otherwise ad-hoc send. Sending neither collection_id + request_id nor request is a 400.

StatusMeaning
200Send attempted — the body always carries status_code; a transport failure still returns 200 with error set and status_code: 0.
400Missing target (no collection_id+request_id and no request), or a malformed payload.
503uiNotRunning — the Probe app must be running to perform a send.
504timeout — the app did not answer in time.

Returns {status_code, status_text, headers, body, duration_ms, resolved_url, error, test_results, console_logs}. resolved_url is the URL as sent, after the scripts' edits. Secret variable values are masked (••••••••) in resolved_url, error, test error messages, console logs, and in the response headers and body when the server echoes one back. Masking prevents accidental exposure only: the send's captured traffic is not masked.

GET /api/v1/compose/history?limit=20&include_bodies=false

Section titled “GET /api/v1/compose/history?limit=20&include_bodies=false”

Read the app's Composer send history — every request/response sent from the Composer editor (including REST/MCP-initiated sends recorded by the send bridge; collection runs aren't recorded), newest first. Read-only; the file is entirely Dart-owned (~/.probe/compose_history.json). Each entry's method, url, sentHeaders and sentBody are the request as sent (after the scripts' edits); snapshot is the request as composed in the editor; collectionId is the collection it was sent from, and noCollection: true marks a send with no collection (an entry with neither was recorded before 2026-10-07, and Probe masks it with every collection's secret values of 4 characters or more and the global ones; an entry sent from a collection or with none is masked with its own collection's and the global secret values of any length). Credential headers, cookies, Set-Cookie, auth secrets, the password of a URL's user info and secret variable values are stored masked (••••••••), so they're masked here too; see History.

ParamTypeDefaultNotes
limitinteger20Clamped to 1..=200. Entries are already newest-first, so this takes N after offset.
offsetinteger0Entries to skip from the newest.
include_bodiesbooleanfalseWhen false, strips responseBody, sentBody, snapshot.body and snapshot.resBody (the editor snapshot's uncapped response body) from each entry to keep the payload small. Truncation metadata (responseBodyTruncated/responseBodyOriginalBytes) is always kept.

Returns {entries, total, offset, next_offset} — total is the full count of entries in the file, independent of limit; next_offset is the offset of the next page, null on the last one. A missing history file returns {"entries": [], "total": 0, "offset": 0, "next_offset": null} with 200, not an error.

GET /api/v1/compose/collections/{id}/export/{format}?request_id=&environment=

Section titled “GET /api/v1/compose/collections/{id}/export/{format}?request_id=&environment=”

Export a saved collection (or one request). Pure data transform — works without the Probe UI. Secret variable values are blanked in every format.

formatOutputContent-Type
postmanPostman Collection v2.1 (folders, auth, cookies, GraphQL, scripts, active-env variables)application/json
curlOne cURL command (request_id) or a script with one command per requesttext/plain
probeProbe-native .pro envelope (probe-compose-collection, version 1)application/json
postman-environmentPostman environment file; secret variables typed secret with empty valuesapplication/json
ParamNotes
request_idExport just this request (postman, curl). 404 if unknown.
environmentEnvironment id or name (postman-environment); default is the active environment. 404 if unknown.

Unknown format returns 400; unknown collection returns 404.

State snapshot — the master enable flag and every rule.

GET /api/v1/map-local/match?method=GET&url=https://...

Section titled “GET /api/v1/map-local/match?method=GET&url=https://...”

Preview which rule (if any) would intercept the given URL. Every answer also carries host_decrypted; when it is false, decryption_hint explains that the proxy never processes that request (an ignored host or a built-in background host, forwarded untouched for every scheme; or an HTTPS host outside the intercept-host allowlist, tunnelled undecrypted), so a matching rule never fires.

{ "matched": true, "rule": { "id": "...", "url_pattern": "..." } }
{ "enabled": true }

Create a rule.

{
"id": "rule-1",
"url_pattern": "api.example.com/users/*",
"method": "GET",
"match_all_methods": false,
"enabled": true,
"source": "inline_body",
"inline_body": "{\"id\":1,\"name\":\"Ada\"}",
"inline_format": "application/json",
"status_code": 200,
"response_headers": { "x-mocked-by": "probe" },
"delay_ms": 0
}
FieldTypeNotes
idstring?Optional; generated as mcp_<ms>_<n> when omitted.
url_patternstringGlob (*, **). Default :443/:80 is stripped before matching.
methodstringMethod to match. Ignored when match_all_methods is true.
match_all_methodsboolWhen true, method is ignored. Default false.
enabledboolPer-rule toggle. Default true.
sourcestring"inline_body" or "local_file". Anything else falls back to inline.
file_pathstring?Required when source = "local_file". Absolute path to an existing regular file of at most 50 MiB inside ~/Downloads, ~/.probe/transfers or ~/.probe/map-local (Windows: %USERPROFILE%\Downloads and the same Probe folders; created when the API starts). Anything else — a path outside those folders, a symlink, .., a missing file, a directory or device file — is a 400 naming the allowed folders, on create and on update, whatever the source. Rules you create in the app may point anywhere; editing such a rule over the API needs its file moved into one of the folders. The file is served to every client whose request matches, including phones on your network.
inline_bodystring?Used when source = "inline_body". Raw bytes (UTF-8 or base64-encoded for binary).
inline_formatstring?MIME hint (application/json, text/html, image/png, …). Used to set Content-Type on the served response.
status_codeintHTTP status to return. Default 200.
response_headersobjectExtra response headers.
delay_msintArtificial delay before responding. Default 0.

Returns the persisted rule.

One rule in full. 404 if the id does not exist.

Patch a rule: only the fields you send change (a key the rule does not have, such as camelCase urlPattern, is 400 naming it), omitted fields keep their stored value, the merged rule is checked like a POST, and an unknown id is 404.

Delete by id. 204.

Flip the rule's enabled flag. Returns the updated rule.

Same endpoint shape as Map Local under /api/v1/map-remote. The rule body differs — it splits source-match criteria from destination-rewrite fields.

State snapshot — the master enable flag and every rule.

GET /api/v1/map-remote/match?method=GET&url=https://...

Section titled “GET /api/v1/map-remote/match?method=GET&url=https://...”

Preview which rule (if any) would rewrite the URL. Returns the matched rule and the rewritten destination URL. Every answer also carries host_decrypted; when it is false, decryption_hint explains that the proxy never processes that request (an ignored host or a built-in background host, forwarded untouched for every scheme; or an HTTPS host outside the intercept-host allowlist, tunnelled undecrypted), so a matching rule never fires.

{ "enabled": true }

Create a rule.

{
"id": "rule-1",
"enabled": true,
"source_scheme": "https",
"source_host": "api.production.com",
"source_port": null,
"source_path": "/v1/*",
"method": "GET",
"match_all_methods": true,
"dest_scheme": "https",
"dest_host": "staging.example.com",
"dest_port": null,
"dest_path": "/v1/*",
"preserve_original_host_header": false
}
FieldTypeNotes
idstring?Optional; generated as mcp_mr_<ms>_<n> when omitted.
enabledboolDefault true.
source_schemestringhttp or https.
source_hoststringHost to match, exactly (case-insensitive, no wildcards): no scheme, port or path. A wildcard such as *.example.com is a 400: add one rule per host.
source_portint?null means scheme default (443/80).
source_pathstringPath glob starting with / (or *). Each * captures any substring, / included (anchored match).
methodstringMethod to match. Ignored when match_all_methods is true.
match_all_methodsboolDefault false.
dest_schemestringhttp or https.
dest_hoststringRewrite target host only: the port goes in dest_port, the scheme in dest_scheme. localhost:3000, http://x or x.com/v2 is a 400 naming the field to use.
dest_portint?null means scheme default.
dest_pathstringRewritten path, starting with / (or *). Each * is filled with the next capture from source_path, in order (there is no $1 syntax — it would be sent literally).
preserve_original_host_headerboolAccepted and stored for compatibility, but it currently has no effect: the forwarded request always carries dest_host. Default false.

Returns the persisted rule.

One rule in full. 404 if the id does not exist.

Patch a rule: only the fields you send change (a key the rule does not have, such as camelCase urlPattern, is 400 naming it), omitted fields keep their stored value, an unknown id is 404, and the merged rule is checked like a POST: preserve_original_host_header: true is refused (not supported yet), an empty source_host/dest_host, a scheme other than http/https, a host with a scheme, port or path, a wildcard source_host, or a path without a leading / is a 400.

Delete by id. 204.

Flip enabled. Returns the updated rule.

The Mock Server is a standalone HTTP listener (default port 9100) that serves canned responses to clients that hit it directly — no proxying involved. It's the third consumer of the same rule-list plumbing as Map Local and Map Remote, with its own listener lifecycle on top.

State snapshot — the master enable flag and every rule.

Listener state.

{ "running": true, "port": 9100 }

Preview which rule (if any) would serve the given method + path. The mock server is called directly, not through the proxy, so this check carries no host_decrypted.

{ "matched": true, "rule": { "id": "...", "url_pattern": "..." } }

Master toggle. When false, every request gets the 404 fallback.

{ "enabled": true }

Start the listener.

{ "port": 9100 }

400 if port is 0 (it must be 1–65535). 409 Conflict if the port is already in use, or the listener is already running. With the app running, the app's Mock Server state follows (start and stop alike).

Stop the listener. 204 No Content.

Create a rule.

{
"id": "rule-1",
"name": "Users list",
"enabled": true,
"method": "GET",
"url_pattern": "/api/users/*",
"status_code": 200,
"status_text": null,
"headers": [["content-type", "application/json"]],
"body": "{\"id\":1,\"name\":\"Ada\"}",
"body_is_base64": false,
"latency_ms": 0
}
FieldTypeNotes
idstring?Optional; generated as mcp_<ms>_<n> when omitted.
namestringDisplay name in the manager.
enabledboolPer-rule toggle.
methodstringMethod to match. Empty string matches any method.
url_patternstringA path, not a full URL (e.g. /api/users/*). * is a wildcard. A pattern without ? ignores the request's query string. First enabled rule wins.
status_codeintDefault 200.
status_textstring?Optional custom reason phrase.
headersarray of [name, value]Response headers.
bodystringResponse body.
body_is_base64boolWhen true, body is decoded from base64 to raw bytes — for binary responses. Default false.
latency_msint?Optional artificial delay before responding.

Returns the persisted rule.

One rule in full. 404 if the id does not exist.

Patch a rule: only the fields you send change (a key the rule does not have, such as camelCase urlPattern, is 400 naming it), omitted fields keep their stored value, the merged rule is checked like a POST (including body_is_base64: true with invalid base64, refused at write time), and an unknown id is 404.

Delete by id. 204.

Flip the rule's enabled flag. Returns the updated rule.

Routes for the Files feature — SFTP, FTP/FTPS, S3, and WebDAV browsing and transfers. They drive the same command/event engine the Flutter Files UI uses, via a synchronous request/reply layer bolted onto that engine (the streaming FRB/UI path is untouched).

There is no connect route by design: credentials (passwords, S3 keys) cross the FRB bridge once, from the UI, and are never persisted in Rust. This surface only operates on connections the user has already opened in the app — list them, then drive file ops and transfers against their ids. These calls never move the folder the user is looking at in the app's Files browser.

Snapshot of every open connection.

{
"connections": [
{ "id": "conn-1", "protocol": "sftp", "host": "example.com", "label": "Prod box", "homePath": "/home/user", "encrypted": true }
]
}

Snapshot of the transfer queue: every active transfer plus the 100 most recently finished.

{
"transfers": [
{ "transferId": "tr_rest_...", "connectionId": "conn-1", "kind": "download", "state": "running", "remotePath": "/var/www/app.log", "localPath": "/Users/me/Downloads/app.log", "bytesDone": 40960 },
{ "transferId": "tr_rest_...", "connectionId": "conn-1", "kind": "upload", "state": "error", "remotePath": "/var/www/deploy.zip", "localPath": "/Users/me/deploy.zip", "bytesDone": 0, "error": { "code": "permissionDenied", "message": "..." } }
]
}

state is one of queued/running/paused/done/cancelled/error. A transfer in the error state carries error: {code, message}. Cancelled transfers stay listed with state cancelled.

POST /api/v1/remote-fs/transfers/{id}/cancel

Section titled “POST /api/v1/remote-fs/transfers/{id}/cancel”

Cancel a queued, running or paused transfer — one started from the app or from this API. It stays in the transfer list with state cancelled; a partly written destination file is left in place.

{ "ok": true, "transferId": "tr_rest_..." }

404 for an unknown transfer id, 400 for a transfer that already finished (the message names its state).

POST /api/v1/remote-fs/connections/{id}/list

Section titled “POST /api/v1/remote-fs/connections/{id}/list”
{ "path": "/var/www", "cursor": "500", "limit": 500 }

cursor and limit are optional: a page holds limit entries (default 500, clamped to 1..=5000) from cursor (the previous page's next_cursor; omit it for the first page). Returns {path, entries, total, next_cursor} — each entry carries name, path, isDir, size, and (when the backend reports them) modifiedMs, permissions, owner, symlinkTarget; total is the entry count of the directory and next_cursor is null on the last page. A cursor this route did not hand out is 400 invalidArgument. The cursor is a position in the listing, so a directory that changes between calls can skip or repeat an entry.

POST /api/v1/remote-fs/connections/{id}/mkdir

Section titled “POST /api/v1/remote-fs/connections/{id}/mkdir”
{ "path": "/var/www/new-folder" }

{ "ok": true } on success.

POST /api/v1/remote-fs/connections/{id}/rename

Section titled “POST /api/v1/remote-fs/connections/{id}/rename”
{ "from": "/var/www/old.txt", "to": "/var/www/new.txt" }

POST /api/v1/remote-fs/connections/{id}/delete

Section titled “POST /api/v1/remote-fs/connections/{id}/delete”
{ "paths": [ { "path": "/var/www/tmp", "is_dir": true } ] }

is_dir (or isDir) defaults to false — needed by backends like S3 that distinguish an object from a prefix. A list entry can be sent as-is (name, size, modifiedMs, permissions, owner, symlinkTarget are ignored); any other member is refused. On S3 a file delete of a key that doesn't exist fails notFound; when the path is a folder sent without is_dir, the message adds "is it a directory? pass isDir: true". Under a delete-only IAM policy (HeadObject forbidden) the file is still deleted and listed in deleted, but also in unconfirmed with a note: its existence couldn't be checked. Directory deletes are recursive on S3 (every object under the prefix) and WebDAV (the server deletes the collection), and non-recursive on SFTP and FTP/FTPS, where a non-empty directory fails. Every path is attempted even after a failure:

OutcomeResponse
all deleted200 {"ok": true, "deleted": [...]}
some deleted207 {"ok": false, "deleted": [...], "failed": [{"path", "error": {"code", "message"}}]}
none deletedthe first failure's status (table below), with the same body plus a top-level error

POST /api/v1/remote-fs/connections/{id}/download

Section titled “POST /api/v1/remote-fs/connections/{id}/download”
{ "remote_path": "/var/www/app.log", "local_path": "/Users/me/Downloads/app.log", "overwrite": false }

local_path must be an absolute file path on the machine running Probe, inside ~/Downloads or Probe's transfers folder ~/.probe/transfers (Windows: %USERPROFILE%\Downloads, %USERPROFILE%\.probe\transfers); the folder must exist. An existing file is refused unless overwrite is true (it is then truncated and replaced); a relative path, a .., a symbolic link, a directory or a path outside those folders is refused too — each a 400 with code invalidArgument whose message names the allowed folders. 202 Accepted with { "transferId": "tr_rest_..." }. Poll GET /api/v1/remote-fs/transfers for progress and completion, or cancel it.

POST /api/v1/remote-fs/connections/{id}/upload

Section titled “POST /api/v1/remote-fs/connections/{id}/upload”
{ "local_path": "/Users/me/Downloads/deploy.zip", "remote_path": "/var/www/deploy.zip" }

local_path must be an absolute path to an existing regular file (otherwise 400 invalidArgument). Same response shape as download.

Every remote-fs route maps backend failures onto a shared {"error":{"code","message"}} body. A request the Files engine doesn't answer in time is 504 with code timeout.

Wire codeHTTP
auth, permissionDenied403
notFound404
alreadyExists, cancelled409
hostUnreachable, hostKeyRejected502
unsupported, protocol400
io (or anything else)500

API Flows are Dart-owned — the graph lives in <data-dir>/flows/*.json, written only by the Flutter app. The GET routes read those files straight off disk; only the running UI can create, change or execute one (503 uiNotRunning without it, ADR-0042).

List flow summaries.

{
"flows": [
{ "id": "flow-1", "name": "Login + fetch profile", "nodeCount": 4, "edgeCount": 3, "createdAt": 1731081822, "updatedAt": 1731082900 }
]
}

Corrupt files are skipped silently, mirroring the Dart loader.

The full stored flow JSON — nodes, edges, canvas viewport — enriched with revision (its updatedAt, what PUT /flows/{id}/graph sends back) and a derived subflowIds array: the deduped target ids of every subflow node, in node order, so a client can discover a flow's sub-flow dependencies without walking node payloads itself. 404 if the id doesn't exist, the file is corrupt, or the id has a character other than a letter, digit, ., _ or -.

Create a flow (needs the app). Body: {"name": "Login"} for an empty flow with one start node, or {"name", "nodes", "edges"?, "viewport"?} for a whole graph in the GET /flows/{id} shape. Unknown body fields are refused. 201 with the stored flow and its revision; requiredInputs lists the variables that replaced literal secrets. 400 when the graph breaks a rule: no start node or more than one, an edge to a missing node, an unknown node type, a duplicate node id or name, a cycle, a condition port with two edges, a sub-flow to an unknown flow or one that would run itself, more than 500 nodes or 2,000 edges, or a body over 1 MiB. Negative node positions move into view together.

Rename a flow: {"name": "New name"} (needs the app). 200 with the flow, 404 for an unknown id.

Replace the whole graph (needs the app): {"revision", "nodes", "edges"?, "viewport"?}, where revision is the one GET /flows/{id} returned. A flow changed since that read (a canvas edit, another client) answers 409 and changes nothing: read again and reapply. Same checks as POST /flows; an embedded request sent back unchanged keeps what is stored. 200 with the flow and its new revision.

Delete a flow and its file (needs the app). 204; 404 for an unknown id.

Ask the running UI to execute the flow and return its report.

{ "environment": { "token": "abc123" } }

environment is optional — per-run variable overrides forwarded verbatim to the Dart executor. The body may be omitted entirely.

ConditionHTTP
flow file missing or corrupt404
no sink registered (app not running)503 — {"error":{"code":"uiNotRunning",...}}
app never answered within the timeout (120 s)504 — {"error":{"code":"timeout","run_id":"…","status":"running",...}}; the run keeps going, fetch it with GET /api/v1/flow-runs/{run_id}
report received200 — the run report from the Dart executor, with secret variable values masked (••••••••)

Runs can take up to two minutes; the route waits out the full timeout before returning 504.

Fetch the report of a run that outlived the 120 s wait, by the run_id in the 504 body. Returns {"run_id", "status": "running"} while the app is still running it, then {"run_id", "status": "finished", "report"}. 404 (unknownRun) for an unknown id: only the last 32 timed-out runs are kept, and a run that finished in time returned its report directly. See Flows in CI for driving this endpoint outside an interactive AI session.

Run a script once in the embedded QuickJS engine (10-second timeout). Nothing is sent unless the script calls pro.sendRequest, which sends a real request (up to 5 per run); the script's variable and request changes are returned, not applied. The body wraps the engine's ScriptInput under an input key:

{
"input": {
"script": "pro.test('shape', () => pro.expect(pro.response.json().id).toBeDefined());",
"is_test": true,
"request_method": "GET",
"request_url": "https://api.example.com/users/1",
"request_headers": [["accept", "application/json"]],
"request_body": null,
"response_code": 200,
"response_status": "OK",
"response_headers": [["content-type", "application/json"]],
"response_body": "{\"id\":1}",
"response_time_ms": 120,
"env_vars": [["base_url", "https://api.example.com"]],
"env_name": "Dev",
"global_vars": [],
"collection_vars": [],
"request_name": "Get user",
"request_id": "req-1"
}
}
FieldNotes
script, request_method, request_url, env_name, request_name, request_idRequired strings.
is_testRequired. true runs a test script (pro.response, pro.test); false a pre-request script.
request_headers, env_vars, global_vars, collection_varsRequired lists of [name, value] pairs (may be empty).
request_bodyOptional string.
response_code, response_status, response_headers, response_body, response_time_ms, response_size_bytes, response_cookiesOptional response context for test scripts.
local_varsOptional [name, value] pairs readable through pro.variables.

An input that doesn't match this shape is a 400 with {"error": "Invalid script input: ...", "script_failed": true}. Otherwise 200 with {console_logs, test_results, variable_mutations, request_mutations, local_variables, script_failed, error, next_request, skip_request}; the log, test and mutation fields are JSON-encoded strings (probe_execute_script decodes them and builds this input from a friendlier shape).

{
"enabled": true,
"rules": [
{
"id": "rule-1",
"name": "Inject trace header",
"method": "GET",
"url_pattern": "api.example.com/**",
"request_script": "pro.request.headers.upsert('x-trace-id', pro.crypto.uuid());",
"response_script": "",
"apply_to_request": true,
"apply_to_response": false,
"enabled": true
}
]
}

The rule's method is case-insensitive (* or empty for all). url_pattern is unanchored: it is matched against the URL with and without its scheme, as a plain substring without * or a wildcard with it (api.x.com also matches evil-api.x.com.io). Rules are checked in list order, only the first matching rule runs, and new rules are appended.

Add a rule. The full ScriptRuleEntry shape is required:

{
"id": "rule-1",
"name": "Inject trace header",
"method": "GET",
"url_pattern": "api.example.com/**",
"request_script": "pro.request.headers.upsert('x-trace-id', pro.crypto.uuid());",
"response_script": "",
"apply_to_request": true,
"apply_to_response": false,
"enabled": true
}
FieldTypeNotes
idstringCaller-supplied unique id. The MCP layer uses mcp_<ms> for auto-generation; pick anything stable.
namestringDisplay name in the UI.
methodstringHTTP method (GET, POST, …) or empty if apply_to_request/apply_to_response are method-agnostic.
url_patternstringGlob (*, **). Match is run against the MITM-shaped URL (with :443/:80); the matcher strips defaults so either form works.
request_scriptstringJS source for the request phase. Empty string = no request hook.
response_scriptstringJS source for the response phase.
apply_to_requestboolRun request_script for matching requests.
apply_to_responseboolRun response_script for matching responses.
enabledboolPer-rule toggle.

Returns the persisted rule. 400 if the body fails to deserialize.

Master switch (separate from per-rule enabled).

{ "enabled": true }

Bulk replace the entire rule list. Body is an array, same element shape as POST /api/v1/script-rules:

[
{ "id": "rule-1", "name": "...", "method": "GET", "url_pattern": "...", "request_script": "...", "response_script": "", "apply_to_request": true, "apply_to_response": false, "enabled": true },
{ "id": "rule-2", "name": "...", "method": "", "url_pattern": "**", "request_script": "", "response_script": "pro.test('200', () => pro.expect(pro.response.code).equals(200));", "apply_to_request": false, "apply_to_response": true, "enabled": true }
]

Replace one rule. Body is the same ScriptRuleEntry shape as POST. The path id and the body id should match.

Delete one rule. 204 on success, 404 if id is unknown.

Read-only snapshots of WebSocket frames Probe captured through the proxy. There is no connect endpoint — the WebSocket client lives in the app; the proxy only observes frames MITM'd through it. Frames are held in a bounded in-memory store (500 frames / 2 MB per connection, 100 connections), so old connections and frames are evicted over time.

List captured WebSocket connections.

{
"items": [
{
"conn_id": 1,
"log_id": 4213,
"url": "wss://api.example.com/ws",
"opened_ts": 1731081822000,
"closed": false,
"closed_ts": null,
"frame_count": 12
}
],
"total": 1
}

log_id is the traffic entry id of the upgrade handshake (the GET with Upgrade: websocket), or null when the capture couldn't be correlated. Use conn_id with the frames route.

GET /api/v1/websocket/connections/{id}/frames

Section titled “GET /api/v1/websocket/connections/{id}/frames”

The captured frames for one connection. 404 if conn_id is unknown (or already evicted).

{
"conn_id": 1,
"url": "wss://api.example.com/ws",
"items": [
{ "dir": "out", "opcode": "text", "text": "{\"type\":\"ping\"}", "size_bytes": 15, "truncated": false, "ts": 1731081822100 }
],
"total": 1
}

dir is in (server → client) or out (client → server). opcode is text / binary / ping / pong / close. For binary frames text is a base64 preview; control frames carry an empty text. Previews are capped at 64 KB — truncated is true when the payload was clipped, and size_bytes always carries the true payload size.

A test run groups the requests one test produces, so an agent or CI job reads exactly those (and notices when some are missing). Runs live in Probe's memory only: nothing is saved, nothing is sent to the app, and they are gone when Probe restarts.

A run covers every request that started after you began it and before you ended it (ids are taken when a request starts, so a slow request that finishes late still belongs). An optional scope narrows it further: client (substring of the client label), host (that host or a subdomain), and header (a request header your test sends, default name X-Probe-Test; with a value it must match exactly, without one the header only has to be present). Several shards sharing one Probe give each run its own header value.

Begin a run. Every field is optional; the body may be empty.

FieldTypeDescription
namestringA label for you.
scope.clientstringSubstring of the client label, case-insensitive.
scope.hoststringHost or a subdomain of it (port ignored).
scope.headerobject{name, value}: name defaults to X-Probe-Test; value omitted = header present.
ttl_secsintLease, default 900, at most 86400. Every access renews it; a run nobody touches for ttl_secs ends itself (end_reason: "expired").
expect_hostsstring[]Hosts you expect traffic for; each gets a host_checks verdict.
{
"id": "tr-3f9a1c0b7d42-1",
"name": "checkout",
"instance_id": "3f9a1c0b7d42",
"state": "running",
"started_at": 1731081900123,
"start_id": 142,
"scope": { "header": { "name": "X-Probe-Test", "value": "shard-1" } },
"ttl_secs": 900,
"expires_at": 1731082800123,
"host_checks": [
{ "host": "api.example.com", "host_decrypted": false, "decryption_hint": "`api.example.com` is outside the intercept-host allowlist, so its HTTPS traffic is tunnelled undecrypted and no rule fires: add it with probe_add_intercept_host" }
]
}

201 with the run. host_decrypted: false means Probe will not see that host's requests (ignored host, built-in background host, or outside the intercept allowlist) and decryption_hint says what to change. At most 64 runs exist at once. When all 64 are still running, beginning another answers 409 tooManyRuns; otherwise the oldest ended run is dropped to make room. Run ids contain the instance_id of the Probe process that made them.

{items, total}, newest first (no stats).

The run plus stats:

StatMeaning
countRequests in the run (CONNECT tunnel rows not counted).
pending_responsesOf those, how many have no response yet.
evictedThe 10,000-row limit dropped rows up to or past the run's start, or a row inside the run's id range was deleted: some requests may be missing.
clearedThe traffic list was cleared since the run began.
first_available_idLowest id still held (or null).
capture_pausedCapture is off or the proxy is stopped right now.
capture_interruptedCapture or the proxy was stopped at any time since the run began.

Errors: unknown id 404 runNotFound; an id from before a Probe restart 404 runFromPreviousInstance (begin a new run).

End the run: requests that start later no longer belong to it. Body { "settle_ms": 300 } (optional, default 300, at most 5000) waits that long for requests that already started to land, then answers the run (state: "ended", end_id, end_reason) with stats. Ending twice answers the same run. An ended run stays readable for an hour.

204. Forget the run.

End and delete every run and every test rule ({ "removed": N }). Without all=true it answers 400.

A test run can carry its own Map Local, Map Remote and Mock rules, so a test can stage a mock or a fault without touching the rules you saved. They are not your rules: they live in Probe's memory with the run, never appear in /map-local, /map-remote or /mock, are never sent to the app or saved, and your master switches are neither read nor changed. They apply only while the run is running (ending, deleting or an expired lease removes them), and only to requests inside the run's scope. For a matching request the test rule is tried before your own rules.

A response served or rewritten by a test rule carries X-Probe-Test-Run: <run id> and X-Probe-Rule: <rule id>.

{ "kind": "map_local", "rule": { "url_pattern": "https://api.example.com/users/*", "inline_body": "{\"ok\":false}", "status_code": 503 } }

kind is map_local, map_remote or mock; rule has the fields (and the same checks, including the Map Local file_path folder rule) of the matching create route: POST /map-local/rules, /map-remote/rules, /mock/rules. 201 { kind, rule }. Errors: 409 runEnded for a finished run, 409 runScopeRequired when the run was begun without a scope (header, client or host), 409 duplicateRuleId, 409 tooManyRules (200 per run).

A mock rule is served by a listener of the run's own on 127.0.0.1 and a free port, started with the run's first mock rule; the answer (and the run JSON) carry mock_base_url. Point the app under test at that URL. It is separate from the Mock server on port 9100 and stops with the run.

{ map_local, map_remote, mock, mock_base_url }.

DELETE /api/v1/test-runs/{id}/rules/{rule_id}

Section titled “DELETE /api/v1/test-runs/{id}/rules/{rule_id}”

204, or 404 ruleNotFound.

204. Removes every test rule of this run (and stops its mock listener); the run itself stays.

CodeMeaning
200 / 202 / 204Success (202 is Files transfer routes acknowledging an enqueued download/upload).
207Files delete: some paths were deleted, others failed (failed lists them).
400Bad request body (compose shape errors name the field; malformed script input). Also invalid local paths, finished-transfer cancels and unsupported/protocol errors from a Files backend.
401Missing or wrong bearer token.
403Files backend auth failure or permission denied.
404Resource (entry id, rule id, flow id, …) doesn't exist.
409Files conflict — entry already exists, or a transfer was already cancelled. A flow graph write whose revision is out of date.
500Server-side error — usually a parse failure on the disk-backed compose collections.
502Files backend unreachable, or it rejected the host key.
503POST /flows/{id}/run, the flow writes and POST /compose/send — the Probe app isn't running to do the work.
504The app didn't answer in time: a flow run or send (120 s), a write the app must save (15 s; the body has "applied_in_proxy": true), or a session read. Also a Files request the engine didn't answer (code timeout).
  • MCP Server — high-level overview of how probe-mcp uses this API.
  • End-to-end testing — the test-run loop with curl, client setup and CI scripts.
  • MCP Tool Reference — every tool maps directly to one of the endpoints here.
  • File Format — the on-disk shape of saved sessions you can fetch via /session-tabs/saved/{id}.
  • Flows in CI — driving POST /flows/{id}/run outside an interactive AI session.