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": "1.4.0",
"started_at": 1731081822
}
  • The file is written with mode 0600 on Unix.
  • A fresh token is generated each Probe launch — restarts invalidate previous tokens.
  • The file is removed when Probe quits cleanly.

Read it once, point your client at http://127.0.0.1:<port>/api/v1/..., send the token in the Authorization header.

Every endpoint except /api/v1/health requires:

Authorization: Bearer <token>

Without the header (or with the wrong token), the API returns 401 Unauthorized.

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":"1.4.0","capturing":true,"entry_count":42, ...}

Public — no auth required. Useful for liveness checks.

{
"status": "ok",
"version": "1.4.0",
"capturing": true,
"entry_count": 142,
"uptime_secs": 3621,
"api_port": 51827
}

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).
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
}

Full detail of one entry — including request/response headers and bodies.

{
"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. Returns 204 No Content.

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

Plain text — a runnable cURL command. Content-Type: text/plain.

Plain text — raw HTTP request and response.

Replay a captured request. Body is {} (no parameters today). Response carries the new entry’s status, headers, and body. The replay also lands in the live traffic list as a new entry.

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

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

Enable recording. Returns 204.

Disable recording — traffic still proxies, it just isn’t stored. Returns 204.

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.

{ "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/*" }

Drop every breakpoint. 204.

A breakpoint or intercept-host match parks the request until you resume it.

Array of {id} — one per paused request.

Release a paused request, optionally rewriting it.

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

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

Array of host strings.

Add a host. 204.

Remove a host. 204.

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.

Hosts that pause matching requests for inspection (a coarser form of breakpoint).

  • GET /api/v1/intercept-hosts
  • PUT /api/v1/intercept-hosts/{host}
  • DELETE /api/v1/intercept-hosts/{host}

Current global throttle state.

Set the global bandwidth cap.

{ "bytes_per_second": 102400 }

Disable the global throttle.

List the scoped throttle rules currently loaded by the proxy. Read-only at this layer — Probe’s UI owns persistence and pushes the list on every mutation.

Active session tabs in the running app.

Entries inside a specific tab.

Sessions persisted to disk under ~/.probe/sessions/.

Entries inside a saved session.

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

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

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"}] }
]
}

Returns the persisted collection (with server-assigned id).

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.

{
"folder_id": null,
"request": {
"name": "Create user",
"method": "POST",
"url": "{{base}}/users",
"headers": [
{ "key": "Content-Type", "value": "application/json", "enabled": true }
],
"queryParams": [],
"body": "{\"name\":\"Ada\",\"email\":\"ada@example.com\"}",
"contentType": "application/json",
"auth": null,
"timeout": 30000
}
}

auth is null to inherit from the collection, or a typed object: {"type": "bearer", "token": "..."}, {"type": "basic", "username": "...", "password": "..."}, {"type": "apiKey", "key": "...", "value": "..."}. Returns the persisted request with its server-assigned id.

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.

Resolve variables and send a saved request through Probe.

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

Returns the response (status, headers, body, duration_ms).

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.

{ "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
idstringCaller-supplied; pick any stable value.
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 on the host running Probe.
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.

Replace a rule. Body shape identical to POST.

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.

{ "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/$1",
"preserve_original_host_header": false
}
FieldTypeNotes
idstringCaller-supplied.
enabledboolDefault true.
source_schemestringhttp or https.
source_hoststringHost to match (no port).
source_portint?null means scheme default (443/80).
source_pathstringPath glob. * captures one segment substring (regex .*, anchored). Captures are addressable in dest_path as $1, $2, … in order.
methodstringMethod to match. Ignored when match_all_methods is true.
match_all_methodsboolDefault false.
dest_schemestringhttp or https.
dest_hoststringRewrite target host.
dest_portint?null means scheme default.
dest_pathstringRewritten path. Use $1, $2, … to substitute captures from source_path.
preserve_original_host_headerboolWhen true, the rewritten request keeps the original Host header — useful for API gateways that route by Host. Default false.

Returns the persisted rule.

Replace a rule. Body shape identical to POST.

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.

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

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

{ "enabled": true }

Start the listener.

{ "port": 9100 }

409 Conflict if the port is already in use, or the listener is already running.

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
idstringCaller-supplied; pick any stable value.
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.

Replace a rule. Body shape identical to POST.

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.

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.

{
"transfers": [
{ "transferId": "tr_rest_...", "connectionId": "conn-1", "kind": "download", "state": "running", "remotePath": "/var/www/app.log", "localPath": "/Users/me/Downloads/app.log", "bytesDone": 40960 }
]
}

state is one of queued/running/paused/done/cancelled/error.

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

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

Returns {path, entries} — each entry carries name, path, isDir, size, and (when the backend reports them) modifiedMs, permissions, owner, symlinkTarget.

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 defaults to false — needed by backends like S3 that distinguish an object from a prefix.

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" }

202 Accepted with { "transferId": "tr_rest_..." }. Poll GET /api/v1/remote-fs/transfers for progress and completion.

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

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

Same response shape as download.

Every remote-fs route maps backend failures onto a shared {"error":{"code","message"}} body:

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 execute one.

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 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 or the file is corrupt.

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 timeout504 — {"error":{"code":"timeout",...}}
report received200 — the run report, forwarded verbatim from the Dart executor

Runs can take up to two minutes; the route waits out the full timeout before returning 504. See Flows in CI for driving this endpoint outside an interactive AI session.

Run an ad-hoc script in test mode. Body wraps the actual input under an input key:

{
"input": {
"script": "pro.test('shape', () => pro.expect(pro.response.json().id).toBeDefined());",
"variables": {},
"request": { "method": "GET", "url": "https://...", "headers": {}, "body": "" },
"response": { "status": 200, "headers": {}, "body": "{\"id\":1}" },
"phase": "test"
}
}

Returns the engine output — console messages, assertion results, mutated request/response, errors.

{
"scripting_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
}
]
}

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 — used by Probe’s UI to push state. 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": [
{
"connId": 1,
"logId": 4213,
"url": "wss://api.example.com/ws",
"openedTs": 1731081822000,
"closed": false,
"closedTs": null,
"frameCount": 12
}
],
"total": 1
}

logId is the traffic entry id of the upgrade handshake (the GET with Upgrade: websocket), or null when the capture couldn’t be correlated. Use connId 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 connId is unknown (or already evicted).

{
"connId": 1,
"url": "wss://api.example.com/ws",
"items": [
{ "dir": "out", "opcode": "text", "text": "{\"type\":\"ping\"}", "sizeBytes": 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 sizeBytes always carries the true payload size.

CodeMeaning
200 / 202 / 204Success (202 is Files transfer routes acknowledging an enqueued download/upload).
400Bad request body. Also 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.
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 only — the Probe app isn’t running to execute the flow.
504POST /flows/{id}/run only — the Probe app didn’t answer within the run timeout.
  • MCP Server — high-level overview of how probe-mcp uses this API.
  • 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.