Skip to content

Vayu Engine API Reference

Base URL: http://127.0.0.1:9876 (default, configurable via --port)

All endpoints return JSON. Every response also carries Cache-Control: no-store (#1507) - see Listeners for why. Every error response has one shape - an error object carrying a machine-readable code and a human-readable message:

{
  "error": {
    "code": "bad_request",
    "message": "Missing required field: name"
  }
}

code is per-status unless the route names a more specific one: bad_request (400), unauthorized (401), forbidden (403), not_found (404), conflict (409), bad_gateway (502), unavailable (503), internal_error (5xx). Routes with their own vocabulary pass it instead - invalid_config (POST /config), invalid_run_config (POST /runs), and the oauth2_* family.

Any per-error detail sits inside the same object, so a client reads one place for the whole failure - item on POST /import/apply, the provider's reply on /oauth2/*:

{
  "error": {
    "code": "oauth2_provider_error",
    "message": "Token endpoint rejected the request: invalid_client",
    "providerStatus": 401,
    "providerError": "invalid_client"
  }
}

Most routes used to emit a flat {"error": "message"} instead, which the app's http-client could not read - every validation message surfaced as a bare HTTP 400 (issue #173). The client still accepts the flat shape so a newer app can read an older engine, but the engine no longer produces it.

Deprecated aliases

The execution and run/metrics routes were consolidated behind a /runs family, /execute, and /runs/:id/metrics. The old paths still work - each is registered as a deprecated alias of its canonical route (same handler, same behavior) and logs a (deprecated alias) marker per request. These aliases will be removed in a future minor release; new clients should use the canonical paths.

Deprecated alias Canonical route
POST /request POST /execute
POST /run POST /runs
GET /run/:id GET /runs/:id
DELETE /run/:id DELETE /runs/:id
POST /run/:id/stop POST /runs/:id/stop
GET /run/:id/report GET /runs/:id/report
GET /metrics/live/:id GET /runs/:id/live
GET /stats/:id?format=json GET /runs/:id/metrics

GET /stats/:id in its SSE mode is legacy DB-polling and is retained wholesale (no canonical rename); prefer GET /runs/:id/live for live metrics.

GET /runs with no query params is likewise a deprecated shape: it returns the pre-pagination bare array of full-configSnapshot rows. Passing any pagination/filter param returns the {data, pagination} envelope with compact summary rows (see GET /runs). The no-param array is removed at the next minor release.

Resource writes: create vs update

Collections, requests and environments follow one write contract. /globals is a singleton and stays POST-only, so it is exempt from the verb split below - but not from the null-vs-absent rule, which it follows with every write treated as a create (see POST /globals).

Verb Path Meaning Wrong-target status
POST /<resource> Create only. 400 when the body carries an id
PUT /<resource>/:id Update only (merge-patch). 404 when the id does not exist

PUT is used loosely as a merge-patch rather than a whole-record replace: an omitted field keeps its stored value. We deliberately did not add a separate PATCH verb - merge-patch is what the update path has always done, and every client call site expects it. The :id in the path is the record's identity; the body carries the changed fields only.

An update is atomic against a concurrent one. The read the merge is computed from, the merge itself and the write are one acquisition of the database lock, so two clients patching one record at the same moment - the app's autosave and an MCP agent, say - each merge onto what the other committed rather than onto the row as both of them found it. The alternative is not a conflict but a silent loss: the merge carries every field the body did not name, so the loser's fields come back written with the values the winner read a moment earlier, and neither request fails. There is no precondition header; a client that needs to detect that the row moved under it has nothing to read yet.

The engine owns every id

A create must not carry an id. The engine generates one via generate_id, giving the <prefix>_<uuidv4> form, and a body containing the field is a 400:

{"error": {"code": "bad_request", "message": "id is assigned by the engine; omit it (bulk import: POST /import/apply)"}}

Presence alone is rejected, null included - id is not a settable field with a default, so the null-vs-absent rule below does not reach it, and accepting {"id": null} would leave a caller believing the field is honoured. The field existed for the import orchestrator, which pre-assigned ids so it could wire parentId / collectionId across a whole tree before anything was persisted; import sends one POST /import/apply instead, referencing items by opaque tempId and reading the real ids back from idMap (issues #96, #97).

On PUT, the path is the identity. A body id matching it is accepted and ignored; one that disagrees - including null - is a 400 (message Body 'id' must match the id in the path ('col_1') or be omitted), because the payload otherwise names two different records and guessing between them is how a PUT to one id rewrites another. This is checked before the record is looked up, so a malformed body answers 400 whether or not the target exists.

Since no create can select an id, the pre-existing "id already exists" 409 - whose body names the update path, e.g. message Collection 'col_1' already exists; use PUT /collections/:id to update - is now reachable only on a generate_id collision, which 122 bits of entropy put out of reach. It stays as a guard: on that draw the alternative is overwriting a live record.

The null-vs-absent rule

One rule, identical for every field of all three resources:

Field absent Field explicitly null
Create (POST) use the default use the default
Update (PUT) keep the current value reset to the default

The defaults are {} for variables, the resource's own default for auth ({"mode":"none"} for a collection, {"mode":"inherit"} for a request), [] for params / headers, "" for description and the two script fields, {"mode":"none"} for a request body, true for followRedirects, 10 for maxRedirects, and false for isActive.

order is the one default that is computed rather than constant: it means "append after the current siblings", on both resources and on both verbs - see Ordering below.

A request's httpVersion is the one field whose default is not fixed: absent or null seeds it from the live defaultHttpVersion config entry ("auto" unless changed), read fresh on every write rather than cached - see POST /requests. Unlike every other field above, an unrecognized httpVersion value is rejected with a 400 rather than silently coerced, because a typo'd protocol silently running as HTTP/1.1 is the worst available outcome.

A field that has no default cannot be reset, so null is a 400 on either verb rather than a silently discarded write. Those fields are a collection's name, an environment's name, and a request's collectionId, name, method and url. Each is also required on create.

A stored JSON field has a write-time cap

One rule, identical for every resource (issue #1485): a request's params / headers / body / auth, a collection's variables / auth / dataSchema / openapi, and an environment's variables are each refused with a 413 when the serialized value is over the engine's field cap (10 MiB, json::MAX_FIELD_SIZE in engine/include/vayu/core/constants.hpp) - naming the field, its size and the cap, and leaving the stored row untouched. POST /import/apply enforces it too, through the same apply_*_field helpers every other write goes through.

A row written before this cap existed can still be oversized. GET /<resource>/:id always answers with the field whole, however large it is - this did not change. Only a request's list route, GET /requests?collectionId=, behaves differently: it has always substituted the field's documented default for a column too large to serve efficiently across a whole page, and now names which fields it substituted for that row in a truncatedFields array (e.g. ["body"]), omitted when nothing was. Neither GET /collections nor GET /environments substitutes or carries this field - see GET /requests.

Ordering

Collections and requests both carry an order column that fixes their position among their siblings - a collection among the children of its parentId, a request among the requests of its collectionId.

Reading. GET /collections and GET /requests sort by order ascending, then createdAt ascending, then id ascending. All three keys are load-bearing: order alone is not a total order (every request created before explicit orders existed sits at 0), and rows are stored with INSERT OR REPLACE on a TEXT primary key, which hands the row a new rowid on every edit - so without an explicit tiebreak, renaming a request silently moved it among its ties. Clients must apply the same rule if they sort locally; the app's compareTreeOrder is pinned to this one by engine/tests/fixtures/tree-order-conformance.json, read by both test suites.

A collection's sub-collections and its requests are two separate blocks, so order never interleaves them - a request and a subfolder in one folder can hold the same value. Where the two blocks sit relative to each other is the tree's rule rather than the column's: subfolders first, which is also the order a recursive collection run executes in, pinned across both consumers by engine/tests/fixtures/recursive-run-order-conformance.json.

Writing. order defaults to "append after the current siblings" - one past the highest order any sibling holds:

Write order
Create, order absent or null appended
Create, order stated as given
Update, order stated as given
Update, order absent, no move unchanged
Update, order absent or null, and the row changes parent (a collection's parentId, a request's collectionId) appended in the destination
Update, order null, no move appended among its current siblings

A move that states no order therefore lands at the end of the destination rather than at whatever slot its position in the old list happened to name. Ordering within a list is always the caller's to state.

Bulk import is the one exception to the append scan: POST /import/apply writes rows that cannot see each other yet, so an omitted order gives the payload's items consecutive slots starting from the append point - see POST /import/apply.

Any writer producing several sibling rows at once owes them distinct orders - the import applier is the instance of a general rule, not a special case. The createdAt leg is a millisecond stamp, so rows written inside one tick tie on it and fall through to id, which compares random UUIDs: stable across reads of the same data, but arbitrary with respect to the order the caller meant. Single-row creates need nothing extra, because the append scan already sees every stored sibling and hands out a fresh slot. The rule is on the writer rather than on a finer timestamp: a microsecond stamp would still tie under a fast enough writer, and it would make row identity depend on clock resolution across the three platforms Vayu builds on.

Repositioning several rows at once is POST /reorder, not a run of PUTs. Each PUT is one row under its own lock, so a reorder expressed as N sibling PUTs can be interrupted halfway, leaving two rows at one order and a gap where the moved one was, and a concurrent client's move lands between any two of them. The batch endpoint validates and writes the whole set under one lock scope, which closes both. What is not a difference any more is the cycle guard: since a PUT holds its own read to its own write, its guard and its write are one scope too, so two conflicting reparents sent as single PUTs are serialized and the second is refused against the first's committed graph - it is refused after that first move is already stored, which is the part only a batch avoids. The per-row PUTs remain correct for a single row - a rename, a move that appends - and still carry those caveats when used in a loop.

Accepted field shapes

A field that is present and not null must have the shape below, on both verbs. Anything else is a 400 naming the field, e.g. message Invalid 'auth': must be a JSON object.

Field Shape
variables (collection / environment / globals) object
auth (collection / request) object
body (request) object - see The request body union
params / headers (request) array of {key: string, value: string, enabled: bool}

Object-shaped fields are stored as JSON blobs, and every reader of one degrades quietly when it is not an object - variables reads back empty, a request body is dropped, and auth resolves to none, so a request the caller believes carries credentials goes out bare. The write is therefore rejected rather than stored: {"variables": 42} and {"auth": "bearer"} are 400s, and the previously stored value is left untouched.

The request body union

body is a discriminated union on mode, and the shape of what it carries depends on which mode it is. The same union is read on the execution endpoints (POST /compose, POST /execute, POST /runs) and stored on a request row, so a body that round-trips through storage sends the same bytes.

mode Carries On the wire
none nothing no body
json / text content (string) content, verbatim
graphql content (string) the GraphQL-over-HTTP envelope on POST, query parameters on GET (see below)
jsonrpc content (string) the JSON-RPC 2.0 call envelope (see below)
xml content (string) content, verbatim
x-www-form-urlencoded fields percent-encoded key=value&…
form-data fields multipart/form-data, boundary engine-generated

fields is an array of {key: string, value: string, enabled?: bool} - the same row shape params and headers use. key is required and must be a string; value defaults to "" and a non-boolean enabled reads as enabled. A form mode carrying no fields array is a 400, as is a fields on a mode whose content is a string. Rows with enabled: false are stored and returned but never sent, so switching one back on needs no re-compose; {{variables}} resolve inside key, value, and a file part's src / fileName / contentType during composition.

Five Content-Type rules follow from the encoding:

  • x-www-form-urlencoded sets Content-Type: application/x-www-form-urlencoded only when the request declares no Content-Type of its own - an explicit header wins.
  • json sets Content-Type: application/json under the same rule (issue #889). It did not until then, and libcurl's default for a POST carrying a body is application/x-www-form-urlencoded - so a request whose body.mode said json went out declaring itself a form. The app's body panel wrote the header row for a request built in the UI, which is why it survived: every request created any other way - MCP create_request, an import, a payload posted straight to /execute - sent the wrong type. A declared header still wins, so application/vnd.api+json reaches the server unchanged.
  • graphql and jsonrpc set Content-Type: application/json under the same rule - a Content-Type the caller wrote wins. graphql only on the method that carries a body at all: a GraphQL GET has no body and derives no Content-Type, since there is nothing to describe (see below).
  • xml sets Content-Type: application/xml, again only when the caller declared none. It has no envelope and nothing is done to content; the header is the whole of what the mode adds over text, which is why an endpoint expecting application/soap+xml keeps the header it was given.
  • form-data always sets its own Content-Type, and a caller-supplied one is dropped. The header has to carry the boundary of the body that was actually encoded, which no caller can name in advance.

text and binary derive nothing, and that is a decision rather than a gap: text/plain, text/csv, a JWT and a raw signature are all text, so there is no single right answer and the header stays the author's.

The graphql envelope

A GraphQL server reads its query out of a JSON object, so a graphql body is enveloped on its way to the wire rather than sent as typed. content may be either shape and the engine normalizes both:

content Sent as
a JSON object with a string query itself, byte for byte
anything else (a bare document, JSON that is not an envelope) {"query": <content>}

The pass-through is byte-exact deliberately: the envelope also carries operationName, variables and whatever else a server has agreed with its clients, and re-serializing it would reorder keys the caller never edited. The app's request builder writes the envelope itself, so its requests take the first row; MCP and raw callers can hand over the document alone and take the second.

One case is neither: content that looks like a JSON object ({ then a quoted key) but does not parse - an envelope whose {{token}} went unresolved, or a mistyped one - is passed through unchanged rather than wrapped. Wrapping a body the engine could not read would turn a broken envelope into a valid request carrying the wrong query. A bare GraphQL document cannot take that shape, so nothing legitimate falls into it.

GraphQL-over-HTTP defines two transports, and the request's method picks between them (issue #1228). Everything above is the POST transport - the envelope goes out as a JSON body. On a GET, the same fields travel as percent-encoded query parameters instead, and the request has no body and no derived Content-Type:

Parameter From
query the envelope's query, or the whole of content when it is a bare document
operationName the envelope's operationName, when present and not null
variables the envelope's variables, JSON-encoded, when present and not null
extensions the envelope's extensions, JSON-encoded, when present and not null

These parameters are merged into whatever query string the request's URL already has, not used to replace it - a GET sent to /graphql?debug=1 keeps debug=1 alongside query=…. The engine sends a body on a GraphQL GET in three cases, each because dropping the alternative would send a request the caller did not write rather than the one it wrote: content is empty, content is envelope-shaped but does not parse (the same unreadable-envelope case above), or the envelope carries a member this transport has no parameter for, or one of operationName / variables / extensions whose value is not the type the specification gives it. In all three, the request falls back to the POST-style JSON body even though the method is GET.

The jsonrpc envelope

A JSON-RPC 2.0 server refuses a frame that does not declare its version, so a jsonrpc body is completed on its way to the wire in the same place, and by the same rule, as the graphql one. content may be either shape:

content Sent as
a JSON object with a string jsonrpc member itself, byte for byte
a JSON object without one itself plus "jsonrpc":"2.0", and "id":1 if it declares no id
a JSON array (a batch call) itself, verbatim
anything else (a non-object, or text that does not parse) itself, verbatim

The added id is the constant 1, never a random or time-derived value: a load run sends the same call thousands of times and a replay has to send the bytes it replays, so a per-send id would make two runs of the same request incomparable. A caller who needs their own ids writes the full frame - which is passed through - and so does a caller sending a notification, the frame with no id that a server must not answer. Members the caller wrote keep the order they wrote them in, and the two the engine may add go on the end.

The member's type is what is checked, not its presence: {"jsonrpc": 2.0} is the JSON number and the spec asks for the string, so such a frame is completed (the version stamped over) rather than sent as the invalid request it is.

A batch array needs nothing done to it - every element carries its own envelope - and passes through for the same reason any non-object does: there is no single call object to complete.

The unresolved-{{token}} caveat is the graphql one, and it matters more here because a JSON-RPC params is where a variable usually sits. Composition resolves the body first (POST /compose), and the envelope rule then runs on the resolved text; a body still holding a token at wire time does not parse, so it is passed through as typed rather than completed into a well-formed request carrying an unresolved template.

File parts (form-data only)

A form-data row with "type": "file" uploads a file from the machine running the engine. It carries a path rather than bytes:

{
  "mode": "form-data",
  "fields": [
    { "key": "caption", "value": "my avatar", "enabled": true },
    {
      "key": "avatar",
      "type": "file",
      "src": "/home/ada/portrait.png",
      "fileName": "profile.png",
      "contentType": "image/png",
      "enabled": true
    }
  ]
}
Member Meaning
type "text" (default) or "file". Any other value is a 400.
src Path the engine opens at transfer time. Required for a file part.
fileName Name the part declares. Defaults to the basename of src.
contentType Per-part Content-Type. Defaults to libcurl's guess.

Rules, all of them refusals rather than silent omissions:

  • A file part in an x-www-form-urlencoded body is a 400 - that media type's wire form is a string of pairs and has no file form.
  • A src on a part that is not "type": "file" is a 400: it names a file nothing would send.
  • An enabled file part whose src is empty, or whose file this process cannot read, fails the request before it is sent - statusCode: 0, errorCode: INTERNAL_ERROR, and a message naming the field and the path. Identical on POST /execute and POST /runs. A disabled file part is neither sent nor opened.
  • In a load run the file is read from disk on every iteration (libcurl streams it during the transfer), and the readability check above costs one open per request. A body with no file part pays neither.

MCP has no file-part surface: run_request / create_request / update_request describe a body as a string, and a path on the user's machine is not something an agent can choose for them - see MCP. The renderer authors file parts in the form-data editor, and the Postman and Insomnia importers map them to file rows.

The older mode spellings form (for x-www-form-urlencoded) and formdata (for form-data) are still accepted on input; responses always use the long names.

Behavior change (pre-1.0)

A create carrying an id is now a 400 on all three resources. External scripts that minted their own ids must drop the field and read the engine's id out of the response, or use POST /import/apply for a whole tree. A PUT whose body id disagrees with the path id is a 400 for the same reason (it used to be silently ignored, so the write landed on the path id).

POST /<resource> used to be a silent upsert on all three resources, so a client could send an update as a POST. That no longer works; external scripts that relied on POST-as-update must switch to PUT /<resource>/:id. Two bugs went with the old behavior and are fixed here:

  • A stale or typo'd id silently created a second record instead of failing, and an id collision silently merged two records into one.
  • POST /environments had no null guard, so {"variables": null} stored the literal four-character text null - JSON that parses but is not an object, so every reader saw an environment with no variables and no error. It now resets to {} like every other resource.
  • environments.isActive was honored only on create; it now follows the rule on both verbs.

Health & Configuration

GET /health

Check engine status and version.

Response:

{
  "status": "ok",
  "version": "0.3.0",
  "workers": 8
}

workers is the workers setting's effective value: the configured count when it is set, otherwise the detected core count - the same value the next run's EventLoop will use, not the machine's raw core count regardless of configuration.

recovery (optional, issue #922) - present only when this engine startup had to recover the database, and absent on a clean start rather than null:

{
  "status": "ok",
  "version": "0.3.0",
  "workers": 8,
  "recovery": {
    "outcome": "started_fresh_quarantined",
    "at": 1755870000000,
    "databasePath": "/home/someone/.local/share/vayu/vayu.db",
    "quarantinedPath": "/home/someone/.local/share/vayu/vayu.db.corrupt-1755870000000"
  }
}
Field Meaning
outcome restored_from_backup - <db>.bak passed the same validation and was restored over the corrupt database. started_fresh_quarantined - no backup restored it, so it was moved aside and a fresh one created. backup_also_corrupt - a backup existed, failed the same validation, and was left untouched rather than restored. deleted_corrupt - it was deleted, which an engine older than issue #984 always did and this one does only when the quarantine rename fails
at When that happened, epoch milliseconds
databasePath The database file it happened to
quarantinedPath Where the unopenable database was moved to, readable with sqlite3 <path> .recover. Optional - absent for deleted_corrupt and for markers written before #984, so test for it rather than deriving it from outcome

The node is read from a marker file beside the database and stands until a later recovery overwrites it, so a client that has already told the user about an event should key off at rather than expecting the node to disappear. See db-schema.md.

POST /shutdown

Gracefully shut down the engine. This is the shutdown path the Electron app uses on quit (it is more reliable than a signal on Windows, where SIGTERM does not behave as expected).

The response is sent before shutdown begins, so the client always receives the 200. About 100ms later, on a detached thread, the engine invokes its shutdown callback: the daemon's main loop exits, active runs are stopped, the lock file is released, logs are flushed, and the process exits.

Response: 200

{
  "status": "ok",
  "message": "Shutdown initiated"
}

GET /config

Get global configuration settings. Backed by the config_entries table. The response is an entries array; each entry carries its value plus the UI metadata the Settings panel renders (label, description, category, default, and optional min/max/options):

{
  "entries": [
    {
      "key": "workers",
      "value": "8",
      "type": "integer",
      "label": "Worker Threads",
      "description": "Number of background worker threads. Higher values improve throughput on multi-core systems but increase RAM usage. Default equals CPU core count.",
      "category": "general_engine",
      "default": "8",
      "min": "1",
      "max": "128",
      "requiresRestart": false,
      "advanced": false,
      "keywords": ["cores", "parallelism"],
      "updatedAt": 1234567890
    },
    {
      "key": "defaultHttpVersion",
      "value": "auto",
      "type": "enum",
      "label": "Default HTTP Version",
      "description": "Protocol a newly created request starts with...",
      "category": "network_performance",
      "default": "auto",
      "options": [
        { "value": "auto", "label": "Auto" },
        { "value": "http1.1", "label": "HTTP/1.x" },
        { "value": "http2", "label": "HTTP/2" }
      ],
      "requiresRestart": false,
      "advanced": false,
      "keywords": ["h2", "alpn"],
      "updatedAt": 1234567890
    },
    {
      "key": "dbCacheSize",
      "value": "67108864",
      "type": "integer",
      "label": "Database Cache Size",
      "description": "Memory SQLite keeps per connection for recently used database pages...",
      "category": "general_engine",
      "default": "67108864",
      "min": "1048576",
      "max": "1073741824",
      "unit": "bytes",
      "requiresRestart": true,
      "advanced": false,
      "keywords": ["ram"],
      "updatedAt": 1234567890
    }
  ]
}

Only entries this engine's own build declares are listed (issue #1492): a row a newer engine wrote to the same workspace, then left behind for an older one to reopen, is never in entries - this engine has no label, validator or bounds for it, so serving it would describe a setting it cannot actually explain. POST /config applies the same rule to writes: a key outside this build's catalogue is refused as "Unknown config key", the same message a key with no stored row at all gets.

min and max are present only for entries that declare them (numeric types). options is present only for type: "enum" entries - a JSON array of {value, label}, so the renderer can draw a picker without a second, hand-maintained value-to-label map. value and default are always strings; type is one of integer, number, boolean, string, or enum.

dependsOn (issue #1610) is present only on an entry that means nothing until a boolean sibling in the same category is on - correlationIdHeader carries "dependsOn": "correlationIdEnabled". It is a catalogue fact the seed asserts is internally consistent (a value naming an unknown, cross-category or non-boolean key fails engine startup); POST /config never reads it back. The Settings screen nests the dependent's card immediately beneath its parent's, indented and disabled with a "Turn on \<parent label> to use this" hint while the parent reads "false".

requiresRestart and advanced are booleans, always present:

  • requiresRestart - the running engine keeps the old value until it is restarted; anything else takes effect on the next run, inbox or request that reads it. It is the only statement of that fact: labels and descriptions no longer spell it out, and the app renders one chip from this field (a pending signal in the Dock too, once such a setting has been saved). Consumers must read the field rather than parse the label - the old (Requires Restart) suffix drifted out of step with the mechanism and misinformed the settings screen and the MCP update_config result at the same time. Exactly three entries carry it - dbCacheSize, dbBusyTimeout and dbSynchronous, all read when the database is opened. workers carried it too until #873, which is the same drift in the field's own terms: the engine reads it at the start of every run, so the restart it asked for was never needed. config_route_test.cpp pins the set.
  • advanced - an internal with no everyday user story (dbBusyTimeout, the four oauth2Refresh* watchdog knobs, inboxLivePollIntervalMs, sseIdleTimeoutMs, maxStepsPerIteration, monitorScrapeTimeoutMs, liveMaxRetainedTicks, scriptStackSize - eleven entries). Still live and still settable; the app renders these collapsed under an "Advanced" section at the bottom of their category. The membership rule is citable: if an entry's own description has to say "only if" or "only for", the entry has declared itself advanced.

keywords is an array of strings, always present and empty for the entries that declare none - a client never has to tell "declares none" from "this engine does not send the field". They are extra terms the app's settings search matches on: what a user types that this entry's key, label and description never say ("ram" for dbCacheSize, "deadline" for defaultTimeout, "fsync" for dbSynchronous). They are match terms only and are never displayed, and a seeded keyword never repeats a word the entry already carries - search reaches the other three fields first and ranks them higher, so a duplicate would only push the entry above better matches. A test over the seeded catalogue enforces both halves of that.

unit says what a numeric entry's value measures - ms, sec, days or bytes today - and is omitted when the entry measures nothing, the same shape as min / max / options above rather than a null. Absent means the number is a count (worker threads, retained runs, stored steps); a suffix reading "items" would be noise, so counts declare none. Non-numeric entries never declare one.

The app renders it as the suffix inside the input, which is where a unit is stated once - so a seeded description never spells the same unit out as an "in milliseconds" clause, and a label never carries a (ms) suffix; a test over the catalogue enforces both. bytes additionally selects human-readable formatting for the value, the range hint and the default line (104857600 reads as 100.0 MB), which the app used to select from a hardcoded list of three keys - so a byte-valued entry added engine-side was formatted as a raw number until someone edited a TypeScript array. A client that meets a unit it does not know should show it verbatim rather than drop it.

Three entries are seeded as enum today. defaultHttpVersion is the protocol a newly created request starts with (see POST /requests). It is a write-time seed only - changing it never alters a request that already exists, and it is never consulted at execution time. dbSynchronous is SQLite's durability level, whose three values ("0" Off, "1" Normal, "2" Full) are an enumeration rather than a range; it is stored as an enum so the panel draws a picker instead of an integer box the description has to explain. proxyMode is the third - see Proxy settings below.

The Settings panel renders entries dynamically, so new keys appear without app changes. The entries below span three categories: the data_retention keys govern how much of a run is kept on disk; maxResponseBodyBytes is limits, because it fails an oversized read in flight and stores nothing; and phaseHistograms joins the three monitor* keys in observability, which governs what a run measures rather than what it keeps:

Key Default Range Effect
maxTraceBodyBytes 5242880 1024–104857600 Largest request/response body stored in a design run's trace_data. Bigger bodies are truncated with bodyTruncated/bodyBytes (see GET /runs/:id).
maxResponseBodyBytes 33554432 1024–1073741824 Largest response body a load-test transfer reads into memory, and what a pm.sendRequest from that run's deferred tests script may read. A bigger response fails that request (see POST /runs). Not a storage cap and unrelated to maxTraceBodyBytes, which truncates what a completed design request writes to the database.
maxDesignResponseBodyBytes 33554432 1024–1073741824 Largest response body a design-mode send - POST /execute, and each step of a collection run - reads into memory, and what a pm.sendRequest from that send's scripts may read. A bigger response is read up to this point and answered with bodyCapped: true (see POST /execute) rather than failing, because someone is watching for it - a script's own fetch is the exception and refuses, having no way to tell the script its body was cut (see scripting). Separate from maxResponseBodyBytes above, which is the load path's and refuses in every case.
maxElementBodyBytes 1048576 1024–1073741824 Largest response body an extract.json or assert.jsonpath element will parse as JSON, in a design send, a collection run and a scenario load run alike. A bigger response is not parsed at all: every JSON-reading element on that step reports skipped, with the reason, instead of paying for - or failing on - a parse of an oversized body. The one shared parse per step is reused by every such element, so this is a per-step cost, not a per-element one.
maxSampleBodyBytes 32768 0–104857600 Largest response body kept for a single captured load-run sample. Bigger bodies are stored truncated and marked. Deliberately far below maxTraceBodyBytes: a design run stores one exchange the user asked for, a load run stores tens nobody asked for individually. 0 keeps headers and metadata and no body.
maxSampleBytes 2097152 0–1073741824 Total captured body bytes one load run may store. Once spent, samples keep their headers and metadata and only their bodies are dropped; the report counts them as sampling.sampleBodiesDropped.
maxResponseSampleBytes 268435456 0–1073741824 Total response-body bytes one load run may hold for its post-run test scripts and schema checks. Two orders of magnitude above maxSampleBytes because these bodies are kept whole - a truncated one would fail a check the target passed - so past the budget whole samples are dropped instead, counted as sampling.responseSamplesDropped. 0 retains no sample that has a body.
phaseHistograms true boolean Record DNS/connect/TLS/first-byte/download times for every load-test completion into five HdrHistograms, so the report can carry timingBreakdown.phases percentiles instead of averages over the retained trace sample. Costs five atomic histogram writes per completion; see benchmarks.
maxRunsRetained 200 0–100000 Keep at most this many most-recent runs; older runs (and their metrics/results, including captured response bodies) are pruned at startup and after each run finishes. 0 = unlimited. Captured data is stored verbatim, so this doubles as its expiry.
runRetentionDays 30 0–3650 Delete runs older than this many days. 0 = unlimited.
trashRetentionDays 30 0–3650 Destroy collections and requests deleted more than this many days ago, swept at startup. Until then they are restorable - see Trash. 0 keeps the trash forever.
monitorIntervalMs 1000 250–60000 Scrape cadence for a monitor block that names no intervalMs of its own. Read per run, so a change applies to the next run started. The bounds on a block's own intervalMs are fixed at 250–60000 either way - they exist to stop a cadence that measures the scraper rather than the target.
monitorMaxSeries 8 1–64 How many metric names one run may chart from its monitored endpoint. A longer series list is a 400. Raising it past 4 repeats chart colours (the categorical palette has four line-legible hues).
monitorScrapeTimeoutMs 0 0–60000 How long one scrape may take before it counts as a gap. 0 derives it from the cadence in force for that run - three quarters of the interval. Set it explicitly for an exposition that is slow to render: one taking longer than three quarters of the interval fails every scrape otherwise, and the only other way out is a slower cadence, which also thins the data. A value longer than the interval a run scrapes at is shortened to it (logged once per run), because a scrape that outlives its own cadence puts the loop behind itself.

Proxy settings

Four network_performance entries decide how every outbound request leaves the machine - design sends, load runs, SSE streams, OAuth token acquisition, POST /import/fetch (which spec re-fetch and $ref bundling ride) and the monitor scrape alike. They are read at the point of use, so a change applies to the next transfer with no restart; a load run and a collection run read the policy once at run start and hold it for the run, because libcurl reuses a pooled connection only when its proxy configuration matches.

Key Default Values Effect
proxyMode environment environment, system, manual, off Where the proxy comes from. environment is libcurl's own http_proxy / https_proxy pickup - what a terminal-launched engine already got, and what a desktop launch usually inherits nothing of. system uses proxySystemUrl, which the app resolves from the operating system. manual uses proxyUrl. off sends direct and also disables the environment pickup.
proxyUrl "" curl-shaped URL The proxy for manual mode: scheme://user:password@host:port. The scheme selects the kind (http, https, socks4, socks4a, socks5, socks5h, socks5t); a scheme-less host:port means http://. Credentials in the URL become basic proxy authentication.
proxySystemUrl "" curl-shaped URL The proxy for system mode, written by the app, not typed by the user. The Electron main process resolves the OS proxy through Chromium and stores the answer here at startup, when the machine wakes, and when the renderer sees the network change. Empty means nothing resolved - see the fallback below. Validated exactly as proxyUrl is, under every mode.
proxyBypass "" comma-separated hosts Hosts that skip the proxy, passed to curl's NOPROXY verbatim: a leading dot matches a domain and everything under it, a single * bypasses everything.

proxyBypass and an inherited no_proxy interact by mode, deliberately. libcurl consults the process's no_proxy variable whenever CURLOPT_NOPROXY is unset, so:

  • Under manual, this list is the entire rule and is always written, empty or not. An empty list exempts nothing, and an inherited no_proxy is ignored. Anything else means a user who named a proxy in Settings has their traffic silently exempted from it by an ambient variable - which is the same invisible failure the mode exists to fix, and not hypothetical: a container exporting no_proxy=...,127.0.0.1,... bypasses a configured proxy for every local target and says nothing.
  • Under environment, an empty list defers to no_proxy - "do what the environment says" includes the exemptions it names - and a non-empty one overrides it.
  • Under system it follows whichever of those two the mode resolved to: the manual rule once a proxy is in force, the environment rule when nothing resolved.
  • Under off there is no proxy for a bypass list to modify.

system mode has two limitations, and both are disclosed rather than hidden. The engine cannot resolve an OS proxy itself - that answer lives behind Chromium's network stack, which libcurl sees none of - so the resolution is the app's and this setting is the channel:

  • PAC is resolved once, not per request. A PAC script answers per URL and the engine cannot call back into Chromium for every transfer, so the app resolves against one probe URL and that answer applies engine-wide. A configuration returning different proxies for different URLs needs manual.
  • A headless engine falls back to environment. With no app running, proxySystemUrl is empty and system behaves as environment does - not as off, because the environment pickup is the closest thing to "what this machine would do" that a daemon on its own has. An unusable stored value falls back the same way, with the reason logged.

POST /config enforces one cross-field rule these three have and the per-key validation cannot express: proxyMode: "manual" requires a usable proxyUrl, judged against the state the update would leave rather than against the keys in the body, so setting the mode alone is a 400 and so is clearing the URL while the mode is already manual. A malformed URL is rejected under any mode; a valid URL stored while the mode is off is kept untouched, which is how a proxy is switched off temporarily without losing it.

A failure of the proxy hop is reported as its own error code, PROXY_ERROR, rather than as the target's: an unresolvable proxy hostname (previously CONNECTION_FAILED, which sent people debugging an endpoint that was never reached), a SOCKS handshake failure, and a 4xx answered to a CONNECT - including the 407 a proxy demanding authentication returns, which curl reports as a generic receive error and which previously surfaced as INTERNAL_ERROR. The code is appended to the error enumeration, so the numeric values stored in existing traces are unchanged.

Cookies are unaffected by any of this: libcurl owns the wire cookies and matches them on the origin host, never the proxy hop.

Default request headers

Four network_performance entries decide what Vayu adds to a request nobody wrote it into (issue #1229). They are read at the top of a request or a run, so a change applies to the next send; a load run reads them once at run start, as it does the proxy policy.

Key Default Values Effect
negotiateCompression true boolean Ask for a compressed response on a Send, a collection or scenario run, and a script's own pm.sendRequest. The value advertised is what this libcurl can decode, read off curl_version_info - gzip, deflate plus br and zstd where the build has them - and libcurl decodes the response before the engine sees it. Off sends no Accept-Encoding at all.
loadNegotiateCompression true boolean The same decision for a load run, separate because compression is part of what a load test measures: off measures a server's uncompressed ceiling, on measures what its clients actually get.
correlationIdEnabled false boolean Send a header carrying a fresh identifier with every request, so one send - or one iteration of a load run - can be found in a server's log. Off by default: it is a header the target did not ask for.
correlationIdHeader X-Vayu-Request-Id header name Which name that identifier goes out under. Vendor-namespaced by default so it collides with nothing a gateway defines; set it to X-Request-ID or X-Correlation-ID for infrastructure that reads one of those. POST /config refuses a value that is not a header name (RFC 9110 token), because a broken name would otherwise put a broken line on every request afterwards.

Three rules hold for all of them, and for the User-Agent the engine has always added:

  • A header the request carries wins. Nothing here overwrites a name the request already names, so a browser's User-Agent, a hand-typed correlation id, or an Accept-Encoding: identity is sent exactly as written. A request that names Accept-Encoding itself also gets no decoding: libcurl hands back what arrives, which is what typing that header asks for.
  • Any of them can be refused per send, with disabledDefaultHeaders on POST /execute or POST /runs.
  • None of them is stored. They are applied at send time, so a saved request cannot carry a stale one. A request saved before this change is stripped of the rows an older app wrote into it, once, at startup: X-Vayu-Version always, an X-Request-ID whose value is a bare UUID, and a User-Agent whose value is a Vayu/.... A correlation id or a User-Agent someone typed is left alone.

The correlation id is generated per transfer, not per composition, so every iteration of a load run carries its own.

Every size the API reports is the bytes Vayu holds - bodySize, a stored trace's bodyBytes, a load sample's body_bytes. With negotiation on those are the decoded bytes, because libcurl decodes before the engine counts; with it off they are the identity response, which is the same number. What crossed the network compressed is not measured, and rawRequest is where the negotiated Accept-Encoding line itself can be read back.

TLS trust settings

One more network_performance entry decides who the engine trusts, and it reaches the same six outbound paths the proxy settings do.

Key Default Values Effect
customCaCertificates "" PEM text (text entry) Certificate authorities to trust in addition to the ones this platform already trusts. Pasted content, not a path: a path breaks when the file moves and cannot be shown back in Settings. POST /config refuses text that holds no -----BEGIN CERTIFICATE----- block, and names a pasted private key for what it is.

The engine materializes the bundle as ca-bundle.pem beside its database and points CURLOPT_CAINFO at it, rewriting the file only when the setting's content changes. Native trust is preserved - the mechanism differs by TLS backend, and each is stated rather than assumed:

Platform Backend What the bundle does
Linux OpenSSL (default paths + CURL_CA_FALLBACK) CURLOPT_CAINFO replaces the default bundle, so the materialized file is the platform's own anchors concatenated with the pasted ones. The system bundle is located the way curl locates it: CURL_CA_BUNDLE / SSL_CERT_FILE, then libcurl's compiled-in cainfo, then the standard distribution paths.
macOS OpenSSL Same as Linux, not the OS store this table claimed until issue #818, and the anchors are read from /etc/ssl/cert.pem and merged into the materialized file.
Windows OpenSSL, selected explicitly (issue #851; Schannel before it) The anchors live in the certificate store and Windows ships no PEM bundle to merge with, so the materialized file holds the pasted certificates alone and CURLSSLOPT_NATIVE_CA - set on every Windows handle, pasted CA or not - loads the store beside it. A machine that exports CURL_CA_BUNDLE puts a file back in reach and the merge runs there as it does on Linux.

Three things make that table checkable rather than a claim. The backend column itself is asserted on each CI platform - both its name and that this process selected it rather than inheriting whatever libcurl chose - so it is read off the build rather than out of a port file, the mistake that left the macOS row wrong until issue #818. The selection is not a formality on Windows: the curl port's http2 feature depends on curl[ssl], which resolves to Schannel there, so that libcurl carries both backends and picks between them by reading CURL_SSL_BACKEND from the environment. The engine names OpenSSL before curl initializes, which is what stops a stray environment variable from moving every request onto a backend where client certificates do not work at all. Issue #858 tracks getting Schannel out of the build entirely. CURLOPT_CAINFO is the one transport option a backend can refuse outright, so the engine checks the return code and logs an error naming the backend if it ever does - and the test suite asserts on every CI platform that this build's backend accepts it. And where the anchors come from a file, the suite asserts they were actually found; where they come from the store, that the build accepts the flag that loads it. Either way a merge with nothing to merge would narrow trust to the pasted certificate alone while this page still promised the opposite, so NativeStoreVerificationTest closes it on a wire: a public certificate has to verify with nothing pasted, and go on verifying once an unrelated CA is.

CURLSSLOPT_NATIVE_CA asks the backend to keep consulting the OS store. It is set unconditionally on Windows, where it is the anchor source; elsewhere it is set whenever a bundle is in force, and the merge above is what carries the guarantee.

What the Windows certificate store does not give you. OpenSSL reads it through the Windows crypto API rather than being the OS verifier, so three things Schannel did on its own do not happen:

  • Root certificates only. Intermediates cached in the store are not supplied to the chain builder (curl 12155), so a server that does not send its full chain can fail on Windows where it verified before. Pasting the intermediate into customCaCertificates fixes it.
  • No on-demand root fetch. Windows can download a missing trusted root when Schannel asks for one; nothing here asks, so only roots already in the store count.
  • No revocation through the store. Schannel passed CERT_CHAIN_REVOCATION_CHECK_CHAIN; OpenSSL does not revocation-check the chain unless told to, and the engine does not tell it to on any platform - so Windows now behaves like Linux and macOS here rather than being the strict one.

A certificate that still fails to verify fails at handshake time with libcurl's own SSL_ERROR - the validation on the way in is about the shape of the paste, deliberately, so a chain curl would accept is never refused by a parser of ours.

A TLS connection that never answers is an SSL_ERROR too, whatever code libcurl put on it. The mapping needs that rule because the interesting refusals do not arrive as TLS errors: under TLS 1.3 the server's verdict about the client - a certificate it will not accept, or one it demanded and did not get - reaches us after our own handshake has finished, so libcurl reports the generic receive error of a connection that went away, carrying the alert in its message. A key the stored passphrase does not open arrives as a generic argument error in the same way. All three used to surface as INTERNAL_ERROR, which says the failure was ours. The rule applies only where the mapping has no answer of its own, and only to an https transfer that produced no response line at all - so a 4xx, a timeout, a refused connection and a proxy failure all keep the code they had (issue #802).

Per-request, verifySSL: false turns verification off for one endpoint entirely (see POST /execute); trusting the authority here is the answer that keeps verification on everywhere else.

In-progress (running/pending) runs are never pruned, and neither are runs pinned as baselines (see PUT /runs/:runId/baseline); neither kind counts toward maxRunsRetained.

GET /request-defaults

What the engine will add to a request that names none of it - the set a client renders beside the request's own headers instead of re-deriving it from the config entries above.

{
  "headers": [
    { "name": "User-Agent", "value": "Vayu/0.25.0", "generated": false },
    { "name": "Accept-Encoding", "value": "gzip, deflate", "configKey": "negotiateCompression", "generated": false },
    { "name": "X-Vayu-Request-Id", "generated": true, "configKey": "correlationIdEnabled" }
  ]
}

A default that is switched off is absent rather than listed as disabled. generated: true means the value is made per send, so the row carries no value at all - an empty string would be a value a client would print. configKey names the setting that governs the row, and is absent for the User-Agent, which is always added.

?scope=design|load names the send the answer describes, and defaults to design. Compression is the one default the two resolve differently: a design send (a Send, a collection run, a script's own request) reads negotiateCompression and a load run reads loadNegotiateCompression, so with the two disagreeing the Accept-Encoding row is present under one scope and absent under the other. A scope that is neither is refused with 400 and code invalid_scope rather than served the design answer, which would describe a run the caller is not asking about; an empty ?scope= is absent rather than a scope of "", and answers for design.

POST /config

Update one or more configuration entries. Two body shapes are accepted:

Batch - update several keys at once:

{ "entries": { "workers": "16", "defaultTimeout": "30000" } }

Single - update one key:

{ "key": "workers", "value": "16" }

In both shapes, non-string values (numbers, booleans) are coerced to strings. Each key is validated against its registered type and, for integer / number entries, its min/max range; boolean entries must be "true" or "false"; enum entries (e.g. defaultHttpVersion) must equal one of that entry's stored options values. "Unknown" means outside this engine's own catalogue (see GET /config) - a stored row from a different engine's build does not make a key known to this one. Validation is all-or-nothing: if any key is unknown or out of range, nothing is applied and the response is 400 with the specific reason(s):

{ "error": { "code": "invalid_config", "message": "'workers' must be at most 128 (got 9999)" } }

Success response: 200 - the full updated entries array (same shape as GET /config) plus "success": true.

The validation and the write are one lock scope, and the write is one transaction (issue #1453): a reader (GET /config, or an internal reader like the proxy policy resolver that reads proxyMode and proxyUrl as two separate calls) can never observe some of a batch's keys applied and the rest still stale, and a failure partway through the write leaves every row unchanged rather than the ones written before it.

Workspace

POST /workspace/backup

Write one complete, compacted snapshot of the workspace database into backups/ beside it, then prune older snapshots to maxBackupsRetained (issue #987). Takes no body.

{
  "path": "/home/someone/.local/share/vayu/db/backups/vayu-20260827-124932-118.db",
  "sizeBytes": 2097152,
  "createdAt": 1787745600000,
  "pruned": 1
}
Field Meaning
path The snapshot written. The whole point of the response: restoring is a file copy you perform yourself
sizeBytes Its size on disk. A compacted copy, so smaller than the live database
createdAt When it was taken, epoch milliseconds - the stamp its file name carries
pruned How many older snapshots retention removed in the same call

It runs SQLite's VACUUM INTO, which is why it is safe while the engine is working. Copying vayu.db by hand is not: the -wal beside it holds committed transactions the main file does not, so a hand copy is a database missing its most recent writes. VACUUM INTO reads one consistent snapshot and writes a defragmented database complete on its own, and is read-only with respect to the workspace.

A second backup while one is running is a 409. Two concurrent copies would each write a whole second database - unbounded disk for a button someone double-clicked - and the second would race the first's retention pass. Anything else that goes wrong is a 500 naming what SQLite or the filesystem refused; there is no success response with an empty path.

Retention only removes files this endpoint wrote (vayu-<stamp>.db), so a copy you put in that directory yourself is left where it is. 0 keeps every snapshot.

There is deliberately no restore endpoint. A running engine overwriting the database file it holds open is the failure this feature exists to prevent. Restore by hand with the engine stopped - see architecture.md.

Collections

Collections are folders that organize requests in a hierarchy.

GET /collections

List all collections.

Response:

[
  {
    "id": "col_1234567890",
    "name": "My API",
    "parentId": null,
    "variables": {},
    "order": 0,
    "createdAt": 1234567890
  }
]

POST /collections

Create a collection. Create only - see Resource writes for the shared contract and the null-vs-absent rule.

Request:

{
  "name": "My API",        // Required, no default (null is a 400)
  "parentId": null,         // Optional, null for root
  "order": 0,               // Optional, appended after siblings if omitted - see Ordering
  "variables": {},          // Optional, collection-scoped variables
  "dataSchema": {},         // Optional, the declared data contract - see below
  "openapi": {}             // Optional, the bound spec document - see below
}

Response: The created collection object, carrying the engine-generated id.

Errors: 400 if the body carries an id (the engine owns it), if name is missing or null, or on a cycle (below); 413 naming the field, its size and the cap, when a serialized variables / auth / dataSchema / openapi is over the engine's field cap.

PUT /collections/:id

Update an existing collection. Update only - a 404 when the id does not exist, never a silent create. The body is a merge-patch: an omitted field keeps its value, an explicit null resets it to the default.

Request:

{
  "name": "Renamed",       // Optional; null is a 400 (no default)
  "parentId": null,         // Optional, null moves it to the root
  "order": 3,               // Optional; a move with no order appends - see Ordering
  "variables": null,        // Optional, null resets to {}
  "dataSchema": null,       // Optional, null clears the declared contract
  "openapi": null           // Optional, null unbinds the spec document
}

A parentId that changes the collection's parent and states no order appends the collection among its new siblings, rather than carrying a position from the list it just left. See Ordering.

Response: The updated collection object.

Errors: 404 if the collection does not exist; 400 on a null name or on a cycle (below); 413 naming the field, its size and the cap, when a serialized variables / auth / dataSchema / openapi is over the engine's field cap.

Cycle validation (both verbs): parentId is validated to keep the collection tree acyclic, since a cycle would make the cascade delete below loop forever. Both cases return 400:

  • parentId equal to the collection's own id - message A collection cannot be its own parent.
  • parentId pointing at one of the collection's own descendants (a reparent that would form a cycle) - message Cannot move a collection into its own descendant.

Parent existence is intentionally not checked: the import orchestrator creates collections in bulk, so requiring the parent to exist first would couple to import ordering. Only self-parent and descendant cycles are rejected.

dataSchema (both verbs, and POST /import/apply): the data contract the collection declares - which columns its data files carry, so {{data.column}} and pm.iterationData can be checked before a run (issue #599).

{"columns":["id","email"],"declaredAt":1700000000000,"fileName":"users.csv"}

{} means the collection declares no contract, and is what an absent field on create and an explicit null on update both resolve to. A present value must be an object (400 otherwise, like variables and auth), and its contents are validated: columns an array of unique, non-empty strings - at most 1024 of them, each at most 256 characters - declaredAt a number, fileName a string. Each violation is a 400 naming the field (Invalid 'dataSchema.columns': ...) that writes nothing.

The schema is stored; the file's rows are not, anywhere, and neither is its path - it is machine-local and stays app-side. See Data-driven runs.

openapi (both verbs, and POST /import/apply): the OpenAPI document this collection is bound to (issue #637).

{"specId":"spec_3f2b1c9a-...","specHash":"<hex sha256>","syncedAt":1700000000000}

{} means bound to nothing, and is what an absent field on create and an explicit null on update both resolve to - so unbinding is {"openapi": null} rather than a verb of its own. A present value must be an object (400 otherwise, like variables and dataSchema). A non-empty one is a binding and is validated further: specId must be a non-empty string that resolves to a stored spec document (400 naming the id otherwise), specHash a string, syncedAt a number.

specHash records which version of the document the collection was last synced to; a scenario run of a bound collection stamps both values into its snapshot and report (see GET /runs/:runId/report).

Send the specId alone and the engine fills in the rest (issue #709): every write path stamps specHash from the document the id names and syncedAt from the moment of the write, so a binding cannot be stored without the version that contract coverage and response-schema validation compare against. Only the halves you omit are filled - a binding that states an older specHash keeps it, and a run of it reports hash_mismatch as before.

Deleting the document is refused while a collection binds it - the binding is never cascaded away, see DELETE /specs/:id.

DELETE /collections/:id

Delete a collection and all its requests (cascading delete). The delete is soft (issue #988): the collection and every descendant are stamped deleted_at in a single transaction rather than removed, which is why every read surface stops returning them while GET /trash still can. The walk terminates even if the stored parent_id tree contains a cycle (see db-schema.md - collections).

Restore it with POST /trash/:id/restore; destroy it for good with DELETE /trash/:id, which is also what the startup sweep does once trashRetentionDays has passed.

Response:

{
  "message": "Collection deleted successfully",
  "id": "col_1234567890"
}

Requests

GET /requests

List requests in a collection. Results are ordered by order, then createdAt, then id - the same contract GET /collections has for collections. See Ordering for why the tiebreak is part of the contract.

Query Parameters: - collectionId (required): Collection ID to fetch requests from

Response: An array of request objects, each in the same shape as a GET /requests/:id response: params/headers are arrays of {key, value, enabled} entries and body is a JSON discriminated union (see the requests table in db-schema.md) - with one deliberate exception (issue #1485): a row whose params / headers / body / auth is over the engine's field cap carries that field's documented default here instead of the stored value, and names which fields were substituted in truncatedFields (omitted when nothing was). GET /requests/:id never substitutes - it answers with the field whole, however large.

[
  {
    "id": "req_1234567890",
    "collectionId": "col_1234567890",
    "name": "Get Users",
    "description": "",
    "method": "GET",
    "url": "{{baseUrl}}/users",
    "order": 0,
    "params": [{ "key": "page", "value": "1", "enabled": true }],
    "headers": [{ "key": "Accept", "value": "application/json", "enabled": true }],
    "body": { "mode": "none" },
    "bodyType": "none",
    "auth": { "mode": "inherit" },
    "elements": [],
    "followRedirects": true,
    "maxRedirects": 10,
    "httpVersion": "auto",
    "verifySSL": true,
    "stream": false,
    "updatedAt": 1234567890,
    "createdAt": 1234567890
  }
]

followRedirects / maxRedirects / httpVersion / verifySSL / stream are the request's stored execution options. They are always present in the response: a request saved before these columns existed reads back as the engine defaults (true / 10 / "auto" / true / false), which is the behaviour it already had. httpVersion is "auto" | "http1.1" | "http2" - what was requested, not what was negotiated; see POST /execute for the negotiated value on a response.

GET /requests/:id

Fetch a single request by id, in one lookup. The app uses this to load a restored request tab or a design-run copy on cold start, instead of fetching every collection's request list and scanning them for the id.

Path Parameters: - id (required): The request ID to fetch

Response: The request object, in the same shape as a GET /requests list entry.

{
  "id": "req_1234567890",
  "collectionId": "col_1234567890",
  "name": "Get Users",
  "description": "",
  "method": "GET",
  "url": "{{baseUrl}}/users",
  "order": 0,
  "params": [],
  "headers": [],
  "body": { "mode": "none" },
  "bodyType": "none",
  "auth": { "mode": "inherit" },
  "elements": [],
  "followRedirects": true,
  "maxRedirects": 10,
  "httpVersion": "auto",
  "verifySSL": true,
  "stream": false,
  "createdAt": 1234567890,
  "updatedAt": 1234567890
}

404 when the request genuinely does not exist. This is distinct from a 5xx: the caller relies on that difference to tell a real deletion from an unreachable engine, and must not treat a transport failure as "deleted".

POST /requests

Create a request. Create only - see Resource writes for the shared contract and the null-vs-absent rule.

Request:

{
  "collectionId": "col_1234567890", // Required, no default (null is a 400)
  "name": "Get Users",               // Required, no default (null is a 400)
  "method": "GET",                   // Required: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS
  "url": "{{baseUrl}}/users",        // Required, no default (null is a 400)
  "params": [],                      // Optional, array of {key, value, enabled}
  "headers": [],                     // Optional, array of {key, value, enabled}
  "body": {"mode": "none"},          // Optional, request body
  "bodyType": "none",                // Optional, mirrors body.mode - see The request body union
  "auth": {},                        // Optional, authentication config
  "elements": [],                    // Optional, typed behaviours - see Elements. The only
                                      // script source; preRequestScript/postRequestScript/tests
                                      // are refused (400, naming "elements")
  "order": 0,                        // Optional, appended after the collection's requests if
                                      // omitted - see Ordering
  "followRedirects": true,           // Optional, follow 3xx responses. Default true
  "maxRedirects": 10,                // Optional, hops while following, clamped to 0..100. Default 10
  "httpVersion": "auto",             // Optional: "auto" | "http1.1" | "http2". Absent/null seeds
                                      // from the "defaultHttpVersion" config entry
  "verifySSL": true,                 // Optional, verify the TLS certificate. Default true
  "stream": false,                   // Optional, consume the response as an event stream.
                                      // Default false - see below
  "specOperation": null,             // Optional, which spec operation this request is - see below
  "methodSource": null,              // Optional, which app setting wrote `method` - see below
  "mockResponseMode": "first",       // Optional: "first" | "fixed" | "random". Default "first"
                                      // - which saved example a mock server answers with, see below
  "mockExampleId": null              // Optional, the example id `mockResponseMode: "fixed"` targets
}

params is builder display state, not the query the engine sends. The engine stores it and hands it back verbatim; nothing in the request-composition path ever reads it. url is the wire truth, so a query parameter that must reach the wire belongs in url - the app's Params table keeps the two in step by rewriting url on every edit, and stores disabled rows in params only. A raw API, MCP or import caller that puts the query only in params stores a request that sends none of it (issue #590).

stream is the saved half of POST /execute's stream (issue #574). It records that this endpoint is a text/event-stream, which is a property of the endpoint rather than of one send, so the app's Event stream toggle persists here and a bulk import carries it. The engine never acts on the stored value by itself: POST /execute reads the flag off the payload it is given, and the by-id compose path deliberately does not add it, so an existing caller that composes by id keeps getting a buffered send.

specOperation names which operation of the collection's bound spec this request is (issue #637):

{"operationId": "listPets", "method": "GET", "path": "/pets/{petId}"}

method and path are required inside the object and operationId is optional, because an OpenAPI operation may declare none. path is the templated path from the document, never a concrete URL - it is the identity a re-fetched spec is diffed against - so a path that does not start with / is a 400, as is a missing or empty method / path, a non-object value, or a non-string operationId. null (or absent on create) means the request declares no operation, and both request serializers emit specOperation: null for it - the key is always present, so a client never has to tell "no operation" from "not serialized". Two requests may name the same operation.

methodSource names which app setting last wrote method, still unclaimed by the user (issue #1505). The only value a client writes is "graphql" - the GraphQL body mode setting method to POST on a fresh GET - and null (or absent on create) means no marker: either the user chose method themselves, or nothing has set it. "graphql" is the only accepted value; anything else is a 400. The app clears the marker itself the moment method is written by anything other than the GraphQL switch, so a method the user has since picked is never reverted - the same "still ours" contract KeyValueEntry.source (issue

1481) gives a header row.

mockResponseMode names which saved example a mock server answers with (issue #481 phase 3): "first" (the default, absent or null on create both mean this), "fixed" (the example named by mockExampleId) or "random". Anything else is a 400, the same way an unrecognized methodSource is. mockExampleId follows the same null-vs-absent rule as specOperation: absent keeps the current target, null clears it, and a non-empty string sets it - it is not checked against the request's saved examples at write time, because the target can be created afterwards or deleted later, and a mock server falls back to "first" for either case rather than this write refusing one up front.

Response: The created request object, carrying the engine-generated id.

Errors: 400 if the body carries an id (the engine owns it), if a required field is missing or null, if collectionId names a collection that does not exist (message Collection '<id>' does not exist), on an unrecognized method, on a params / headers entry that is not {key: string, value: string, enabled: bool}, on a methodSource that is not "graphql" or null, on a mockResponseMode that is not "first" / "fixed" / "random", on a mockExampleId that is not a non-empty string or null, or on an httpVersion that is not "auto" / "http1.1" / "http2" (the body names the field and lists the valid values); 413 naming the field, its size and the cap, when a serialized params / headers / body / auth is over the engine's field cap (issue #1485, see below).

Unlike a collection's parentId, a request's collectionId must resolve to a stored collection. A request under no collection is unreachable: no per-collection GET lists it, and no cascade delete ever reaps it. Bulk import is unaffected - POST /import/apply resolves owners from the payload's own temp ids before any row is written.

PUT /requests/:id

Update an existing request. Update only - a 404 when the id does not exist, never a silent create. Merge-patch body, same rule as collections.

Request: any subset of the POST /requests fields, minus id (it is the path). Sending collectionId moves the request to another collection; the id must resolve to a stored collection (400 otherwise), and a move that states no order appends in the destination - see Ordering. An update that states no collectionId is not checked against the request's stored one, so a row stranded before this validation existed stays editable, and repairable by a PUT that moves it somewhere real. Omitting followRedirects / maxRedirects / verifySSL / stream / specOperation / methodSource / mockResponseMode / mockExampleId leaves the stored values untouched; sending null resets them to true / 10 / true / false / "no operation" / "no marker" / "first" / "no target". A non-boolean followRedirects, verifySSL or stream, or a non-integer maxRedirects, is ignored rather than rejected. maxRedirects is clamped to 0..100 on the way in.

httpVersion follows the same null-vs-absent rule as the fields above, but validates more strictly: absent keeps the stored value; explicit null resets it to the live defaultHttpVersion config value; a recognized string stores as given; anything else - an unrecognized string, or a non-string - is a 400 naming the field and the valid values, never silently coerced. Changing the global defaultHttpVersion afterward does not retroactively alter a request already saved; only an explicit null on this request re-seeds it.

preRequestScript, postRequestScript and tests are refused. Issue

1514's cut-over is a clean cut, not a transitional alias: any of these three

keys present (even null is accepted as a no-op, but a real value is not) is a 400 naming elements as the replacement. Scripts are elements now - {"kind": "script.pre" | "script.post", "config": {"script": "..."}}.

elements (issue #1513, run by issue #1514's pipeline) takes the array rule: absent keeps the stored list; null resets it to []; a present value must be an array the element registry accepts (see Elements) or the write is a 400 naming the index, the kind and the field. The only script source - GET /requests/:id and every list response carry it and no longer carry preRequestScript / postRequestScript at all.

Response: The updated request object.

Errors: 404 if the request does not exist; 400 on a null collectionId / name / method / url, a collectionId naming a collection that does not exist, an unrecognized method, a malformed params / headers entry, a malformed specOperation, a methodSource that is not "graphql" or null, a mockResponseMode that is not "first" / "fixed" / "random", a mockExampleId that is not a non-empty string or null, or an httpVersion that is not "auto" / "http1.1" / "http2"; 413 naming the field, its size and the cap, when a serialized params / headers / body / auth is over the engine's field cap (issue #1485, see below).

DELETE /requests/:id

Delete a request. The delete is soft (issue #988) - the row is stamped deleted_at and GET /trash lists it until a purge. Its saved examples stay on the row rather than being removed: every read of them is by request id and runs the owner check first, so they are as unreachable as the request is, and a restore gets them back with it. A purge takes them.

Response:

{
  "message": "Request deleted successfully",
  "id": "req_1234567890"
}

Elements

GET /elements/kinds

The element registry's catalogue (issue #1513): every kind a request's or collection's elements array may name, whatever the engine build actually registers - a kind added in one file (engine/src/core/elements/) appears here with nothing else changed, per Elements's extensibility contract. This is what #1516 (app) and #1517 (MCP) render a kind's editor from, and what docs/engine/elements.md's kind table is checked against.

Response:

[
  {
    "kind": "inherit.disable",
    "version": 1,
    "phases": [],
    "label": "Disable inherited element",
    "description": "Drops one element inherited from an ancestor collection, named by id, out of this request or collection's resolved list.",
    "category": "inherit",
    "hotPathClass": "declarative",
    "collectionOnly": false,
    "configSchema": {
      "type": "object",
      "properties": {
        "elementId": {
          "type": "string",
          "minLength": 1,
          "title": "Element to disable",
          "description": "The id of an inherited element to drop from this request or collection's resolved list."
        }
      },
      "required": ["elementId"],
      "additionalProperties": false
    }
  }
]

Every property of every kind's configSchema carries a title and a description (issue

1607), plus, where relevant, x-vayu-group: "advanced" (folds the property under a disclosure)

or x-vayu-unit ("ms", "%" or "B", a numeric suffix to render) - see Elements.

Phase 0 also registers script.pre and script.post, validate-only like inherit.disable - so a request or collection the startup fold migrated (docs/engine/db-schema.md) can be read back and written as-is without its own elements failing the registry that produced them - plus, in the test build only, a test.echo kind proving the registration path. collectionOnly (issue #1499) is true only for script.setup / script.teardown - the app's Add menu on a request hides a kind marked so, and the registry itself refuses one on a request's own elements with a 400. See Elements for the full kind table.

Trash

What deleting a collection or a request now does, and how to undo it (issue #988). DELETE /collections/:id and DELETE /requests/:id stamp rows instead of removing them; every other read surface filters stamped rows out, so the tree the app sees is unchanged, and these three endpoints are the whole of what the stamp buys.

Two rules decide what a restore puts back:

  • Cohort. One delete stamps its whole subtree with one timestamp, and a restore clears exactly the rows carrying the timestamp of the row it was given. So restoring a collection cannot resurrect a request the user had deleted separately beforehand - that request stays in the trash and becomes a root of its own again, since its collection is live.
  • Re-parent. A restored collection whose parent is gone, or is itself in the trash, comes back at the tree root (parentId cleared). A request has no such root - collectionId is required - so restoring one whose collection is in the trash is a 409 naming the collection to restore first.

Rows sit in the trash until they are purged: explicitly through DELETE /trash/:id, or by the startup sweep once they are older than trashRetentionDays (default 30; 0 keeps them forever).

GET /trash

Everything deleted and still restorable, newest first. Roots only - the rows a user asked to delete, never what their cascade took with them; that is what collections and requests count.

Response:

{
  "items": [
    {
      "id": "col_1234567890",
      "kind": "collection",
      "name": "Payments API",
      "deletedAt": 1787745600000,
      "parentId": null,
      "collections": 2,
      "requests": 14
    }
  ],
  "total": 1
}

kind is "collection" or "request". parentId is a collection's parent (null at the tree root) or a request's owning collection. An empty trash is {"items": [], "total": 0}, never a 404.

POST /trash/:id/restore

Put a deleted collection or request back, with everything the same delete took. Takes no body.

Response: the entry that was restored, plus what happened to it:

{
  "id": "col_1234567890",
  "kind": "collection",
  "name": "Payments API",
  "deletedAt": 1787745600000,
  "parentId": null,
  "collections": 2,
  "requests": 14,
  "restored": true,
  "reparentedToRoot": false
}

Errors:

Status When
404 Nothing in the trash carries that id - a live row, or one already purged
409 A request whose collection is itself deleted or gone; restore the collection first

DELETE /trash/:id

Destroy a deleted collection or request for good, with its whole subtree - requests, and the examples they own. This is the hard cascade soft delete replaced, asked for deliberately; there is no undo for it.

Unlike a restore, a purge is not limited to the cohort: a row an earlier delete left inside the subtree goes too, because a request under a removed collection is reachable by no read and restorable by nothing.

Response: the entry that was purged, with "purged": true. Its collections / requests are the deleted row's own cohort, so they are a floor here rather than the whole: a purge that also swept up an earlier delete's rows destroyed more than they count. A 404 if the trash does not hold that id - which is also what stops a mistyped id from destroying a live collection.

Request examples

Saved example responses for a request: what an importer found next to it (Postman's item.response[], an OpenAPI operation's responses), and the responses a mock server will serve. Nested under the request because an example is owned by exactly one - the owner is checked before the example on every path, so an example reached through the wrong request is a 404, not a cross-request write.

Ordering is part of the contract, not a display preference: the list is returned by order, then createdAt, then id, and a mock server answers with the first example of the matched request. A create that states no order appends after the request's current examples; a bulk import numbers them by payload position, because every row it writes shares one createdAt and would otherwise come back shuffled by the id tiebreak.

Caps. A body over 1 MiB is a 400 rather than a truncation - an example served as if it were whole when it is not is worse than a refused write - and a request holds at most 100 examples.

origin says who wrote the row (issue #588): "import" for everything an importer or a spec sync produced, "user" for one a person saved from a live response. It defaults to "import", which is honest for every row written before the column existed, and an unrecognised value is a 400 rather than a silent fall back - the OpenAPI spec sync (#627) may replace "import" rows wholesale and must never touch a "user" one, so an absorbed typo would cost a user their saved example.

bodyTruncated says the body stops short (issue #659). A mock server serves a stored example verbatim, with nothing in the response to say anything is missing, so an example saved from a capped trace body (maxTraceBodyBytes) is served as though it were a whole response. Only the client that captured it can know, so the flag is stored rather than inferred - a short body is a legitimate body. It defaults to false, which is honest for every row written before the column existed: import copies a whole documented body, and the app's save-as-example is the only writer that ever had a partial one. The app disclosed this in the example's name until the column landed, which a rename at save time erased.

GET /requests/:id/examples

Response: an array of example objects, oldest first:

[
  {
    "id": "exa_1234567890",
    "requestId": "req_1234567890",
    "name": "200 - A user",
    "status": 200,
    "headers": [{"key": "Content-Type", "value": "application/json", "enabled": true}],
    "body": "{\"id\":1}",
    "contentType": "application/json",
    "order": 0,
    "origin": "import",
    "bodyTruncated": false,
    "createdAt": 1730000000000,
    "updatedAt": 1730000000000
  }
]

An empty array and a missing request are different answers: 404 (message Request not found) means the request does not exist, [] means it has no examples yet.

POST /requests/:id/examples

Create one example. Create only, and the engine owns the id - see Resource writes.

Request:

{
  "name": "200 - A user",   // Required, no default (null is a 400)
  "status": 200,             // Optional, must be 100-599. Default 200
  "headers": [],             // Optional, array of {key, value, enabled}
  "body": "",                // Optional. Default ""
  "contentType": "",         // Optional. Default ""
  "order": 0,                // Optional, appended after the request's examples if omitted
  "origin": "import",        // Optional, "import" | "user". Default "import"
  "bodyTruncated": false     // Optional. Default false - true when `body` is
                             // only the first slice of the captured response
}

headers is an array of KeyValueEntry, the same shape a request's headers use - not a JSON object. A stored example is re-served rather than only displayed, so repeated names (Set-Cookie) and the author's ordering both have to survive.

Response: the created example object.

Errors: 404 if the request does not exist. 400 if the body carries an id, if name is missing or null, on a status outside 100-599 (rejected rather than clamped - a stored 700 would be re-served as a status line nobody can send), on a malformed headers entry, on an origin that is neither "import" nor "user", or on a body over the cap. 409 when the request already holds the maximum number of examples.

PUT /requests/:id/examples/:exampleId

Update one example. Update only - a 404 when the example does not exist, and the same 404 when it exists under a different request. Merge-patch body: absent keeps, null resets to the field's default (name has none, so null is a 400).

Response: the updated example object.

DELETE /requests/:id/examples/:exampleId

Deleting an imported example is a decision that lasts (issue #722). A spec sync rewrites the origin: "import" examples of every request it applies a change to, so a removed row used to come back on the next sync of any field - even a rename. The engine now keeps the row as a tombstone instead: the example is gone from every read (the list, a mock server, an export, and a GET or PUT on its id, all 404), and a later sync leaves that response status alone rather than writing the document's example for it back. Re-importing the document is a fresh import and does bring it back, which is the only way it returns. An origin: "user" example is removed outright - nothing re-creates one, so there is no intent to keep.

The response is the same either way, because from the caller's side so is the outcome:

Response:

{
  "message": "Example deleted successfully",
  "id": "exa_1234567890"
}

Specs

OpenAPI documents, stored once and bound to collections by collections.openapi (issue #637). A spec is not owned by a collection: several may bind the same document, and unbinding one must leave it there for the others - so it is a top-level resource with no cascade reaching it, and the rule that keeps that safe is the delete refusal below. What it is not is immortal: see the reclamation rule further down.

The document is stored verbatim and its hash is computed engine-side on every write, never taken from the caller. A scenario run of a bound collection stamps specId + specHash into its snapshot and report, and that stamp only means anything because both sides of a later comparison were computed by the same code on the same bytes.

There is deliberately no PUT /specs/:id: a document that changed is a different document, and rewriting one in place would invalidate the hash every run of every bound collection was stamped with. A re-fetch stores a new document and moves the binding.

A document nothing can reach is reclaimed (issue #718). Having no owner is what let these rows accumulate with no way to die - every sync mints one and moves the binding off the last, and DELETE /specs/:id needs an id no route hands out for an unbound document. So the engine sweeps on one rule:

A document lives while a collection binds it, or while a retained run names it in its snapshot under scenario.openapi.specId.

Run-referenced documents live exactly as long as the runs do, so a report's coverage never describes a contract whose source is gone. The sweep runs as part of ordinary housekeeping - at startup, after each run's retention pass, after a POST /specs/sync, and after a DELETE /collections/:id - and spares any document written in the last 10 minutes, because storing a document is the first of the three writes that bind one and a caller may not have sent the PUT /collections/:id yet. A client that stores a document it does not intend to bind should therefore expect it to go: GET /specs/:id will answer 404 once it is unreachable by that rule. Nothing here changes what DELETE /specs/:id refuses, and no cascade was added - a bound document is untouched.

POST /specs

Store one OpenAPI document. Create only, and the engine owns the id - see Resource writes.

Request:

{
  "content": "{\"openapi\":\"3.1.0\", ...}",  // Required, non-empty, at most maxSpecDocumentBytes
  "sourceUrl": "https://api.example.com/openapi.json"   // Optional; null when pasted or uploaded
}

Response:

{
  "id": "spec_3f2b1c9a-...",
  "content": "{\"openapi\":\"3.1.0\", ...}",
  "sourceUrl": "https://api.example.com/openapi.json",
  "fetchedAt": 1730000000000,
  "hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
  "operations": [
    {"operationId": "listPets", "method": "GET", "path": "/pets", "responses": ["200", "default"]}
  ]
}

operations is derived by the engine, never sent (issue #853). It is a fact about the bytes being stored, like hash, so the engine reads them: content is parsed as JSON and then, failing that, as YAML, and the operation index comes off that parse. Sending an operations field is a 400.

Each row carries a method and the templated path as the document keys it, an operationId when the document declares one, and responses - the status patterns the document declares ("200", "4XX", "default"), verbatim and in document order. A document declaring more than 2000 operations is refused with the count and the cap, because a run holds the index in memory for its life.

What the reader does with a document, stated because the answers are visible in the index:

  • Document order is kept, for paths and for each operation's responses.
  • Both formats: openapi: 3.x and swagger: 2.0 (quoted or not). Anything else declares nothing - a stored file that is not a contract stores fine and reports no coverage.
  • A path item that is a $ref is followed one hop, in-document; one that resolves to nothing drops that path rather than failing the write.
  • The seven methods Vayu executes are indexed, trace is not.
  • A repeated operationId is kept on its first declaration only (issue #715); the later operation is indexed by method and path alone.
  • Anchors, aliases and merge keys (<<) are expanded as js-yaml expands them; an alias naming no anchor, a duplicate mapping key, and a document that expands to far more nodes than its own size are each a 400 naming the problem.

A document that declares no operation stores "no index", which is not the same as an empty contract: GET /specs/:id reads it back as null rather than [], and a run of a collection bound to it reports no coverage block at all. POST /import/apply's spec section and POST /specs/sync derive both indexes through the same helper, so a document cannot acquire or lose one depending on which route stored it.

responseSchemas is the second index the engine derives from the same read (issues #628 and #860) - what the document declares each response looks like. Like operations, it is not a field of the body; sending one is a 400:

{
  "refRoots": {"components": {"schemas": {"Pet": {"type": "object"}}}},
  "operations": [
    {
      "operationId": "getPet",
      "method": "GET",
      "path": "/pets/{petId}",
      "responses": [
        {"status": "200", "contentType": "application/json",
         "schema": {"$ref": "#/components/schemas/Pet"}}
      ]
    }
  ]
}

Schemas are JSON Schema, not OpenAPI's dialect: the engine translates nullable into a union with null, draft-04's boolean exclusiveMinimum into draft-07's numeric one, and drops the OpenAPI-only keywords that constrain no body (discriminator, xml, externalDocs, example) - a nullable passed through untouched would report a null the document permits as a type failure, which is a wrong verdict rather than a missing one. Each schema keeps its $refs and refRoots carries the subtrees they point into once (components.schemas, definitions, x-vayu-bundled), so a shared schema is stored once and a recursive one is a pointer rather than an infinite expansion. A response that is itself a $ref is read through, one hop, in either dialect (#/components/responses/x in 3.x, #/responses/X in 2.0) - the shape GitHub's spec uses for nearly every response. status is the pattern verbatim ("200", "4XX", "default"), contentType is a media type (3.x keeps every one the response declares; 2.0 pairs each response with the operation's produces, falling back to the document's, then to application/json), and a schema may be true or false as well as an object. The serialized index is held to the same maxSpecDocumentBytes cap as the document, and one over it is a 400 naming the count and the cap. A document where no operation declares a schema stores "no index", read back as null, and every response of it reports checked: false with the reason no_index.

Both indexes come off one read of the document, which is what makes them agree: a status the schema index carries is a status the operation index lists for the same operation, and the identity in each is the same identity - the repeated-operationId rule included.

sourceUrl is null rather than "" when the document did not come from a URL, so a client can offer a re-fetch for exactly the documents that have somewhere to re-fetch from.

Errors: 400 if the body carries id, hash, fetchedAt, operations or responseSchemas (all engine-computed), if content is missing, null or empty, if sourceUrl is present and not a string or null, or if the document is larger than the live maxSpecDocumentBytes config entry - default 10 MiB, aligned with the engine's JSON field cap. The size rejection names the byte count, the cap and the setting, and is checked on POST /import/apply too, through the same helper; the document is never stored truncated. A content the engine cannot read as JSON or YAML is a 400 naming the line - storing it would leave a row that coverage, a sync and an export can each do nothing with, and none of those is a good place to find out.

GET /specs/:id

Response: the whole stored document, content included - rendering and validating it is what every reader wants it for. 404 (message Spec not found) when it does not exist.

Read it for the text: an export, a re-fetch comparison, a $ref resolution. A client that only needs to describe the document should read the metadata route below instead - content here is up to maxSpecDocumentBytes (12 MB for Stripe's published spec), and it is sent in full on every read.

GET /specs/:id/meta

Everything about a stored document except the document (issue #712).

Response:

{
  "id": "spec_3f2b1c9a-...",
  "sourceUrl": "https://api.example.com/openapi.json",
  "fetchedAt": 1730000000000,
  "hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
  "contentBytes": 12058329
}

Every field carries the same value GET /specs/:id would give it - the two are one row seen two ways - and 404 is the same body, so a client that falls back from one to the other does not need two shapes of "not found".

content, operations and responseSchemas are absent, not empty. All three are the fields that make a document big (both indexes are extracted from it and grow with it), and an empty one would be indistinguishable from a document that genuinely has none - the distinction operations: null exists to draw. A reader that needs any of them reads the full route.

contentBytes is the document's size as the engine counts it - the same measure maxSpecDocumentBytes refuses a write by, so a size reported here and the limit it was stored under are in one unit.

This route exists because binding metadata lives on the document: the app's Spec tab paints a source and a fetch date, and getting them used to cost a transfer of the whole spec on every first open of the tab.

DELETE /specs/:id

Delete a stored document.

Response:

{
  "message": "Spec deleted successfully",
  "id": "spec_3f2b1c9a-..."
}

Errors: 404 when it does not exist. 409 while any collection still binds it, with a message naming the first binder (and a count of the rest) so the caller knows what to unbind without a second round trip:

{
  "error": {
    "code": "conflict",
    "message": "Spec 'spec_3f2b...' is bound by collection 'Pets API' (col_9a1f...); unbind it before deleting the document"
  }
}

The refusal is deliberate rather than a cascade to unbound: the caller asked to delete a document, not to edit collections it never mentioned. Unbind with PUT /collections/:id and {"openapi": null}, then delete.

POST /specs/match

Pair the requests in a collection's subtree with the operations a document declares - what binding a collection that already exists needs, and what an import does not (an import creates the requests, so it stamps each one's identity as it builds it).

Reads only. Nothing is stored, stamped or created, so a caller may ask about a document it has not decided to bind - which is exactly what the app's Spec tab does, showing the counts before the user commits. The write is POST /specs/bind, which matches again over the same walk and the same rule rather than being handed this answer: between a preview and a commit a request may have moved, so what the two share is the rule, not a result carried between them.

Request:

{
  "collectionId": "col_9a1f...",
  "operations": [
    { "operationId": "listPets", "method": "GET", "path": "/pets" },
    { "method": "GET", "path": "/pets/{petId}" }
  ]
}

operations are the same identity rows the engine derives into a document's operations index, validated by the same rule - so a set the store would refuse cannot be matched against here first. They are sent here (unlike on POST /specs, which reads them off the document) because this route matches against a document that has not been stored yet, which is the point of a bind preview. Get them from POST /specs/describe rather than by reading the document yourself (issue #869): that route is the same reader POST /specs/bind derives its stamps from, so a preview and the write it previews cannot pair the requests differently. responses may ride along and is ignored.

The requests are not sent. The engine gathers the whole subtree of collectionId itself, because an OpenAPI import binds the root and files every request under one sub-collection per tag: a caller sending "the collection's requests" would be matching against a set that excludes almost all of them.

Response:

{
  "matched": [
    {
      "requestId": "req_4c8e...",
      "operation": { "operationId": "listPets", "method": "GET", "path": "/pets" }
    }
  ],
  "unmatchedRequests": ["req_7b21..."],
  "unmatchedOperations": [{ "method": "GET", "path": "/pets/{petId}" }]
}

operationId is absent rather than "" for an operation that declares none, the same way a stored spec_operation omits it.

How it matches. Both sides are reduced to a path shape: the origin dropped ({{baseUrl}}, https://api.example.com and a schemeless host alike), the query and fragment dropped, and every placeholder - Vayu's {{petId}} and the document's {petId} - flattened to {}. Flattening the name too is deliberate: a document that renames its path parameter describes the same endpoint, and a match that turned on the parameter's spelling would report a rename as removed and added. A second pass then offers each remaining request to the templates it could be an instance of, because a hand-built collection writes the id in (/pets/42) - a literal path in the document wins first, which is OpenAPI's own precedence. Ambiguity is refused in both directions: two requests reducing to one shape, or one request that could be two operations, leaves all of them unmatched. A wrong identity is worse than none, because POST /specs/sync applies changes by it.

Errors: 400 for a missing or empty collectionId, a missing operations, or an operation row the store would refuse. 404 when the collection does not exist - not an empty match, since "this document matches none of your requests" and "you named a collection that is not there" are different answers.

POST /specs/describe

Say what a document is, without storing it: the dialect that claimed it, what it calls itself, and every operation it declares. The read behind a bind preview - the app's Spec tab paints its card from this and hands the identities straight to POST /specs/match.

Reads only. Nothing is stored, which is why the document rides in the body: a caller asks about a file the user has not decided to bind, and a route that stored one to describe it would leave a row behind for every file merely looked at. Describing a document that is stored is GET /specs/:id/meta.

Request:

{
  "content": "{\"openapi\":\"3.1.0\", ...}"   // Required, non-empty, at most maxSpecDocumentBytes
}

Response:

{
  "format": "OpenAPI 3.0",
  "title": "Pets API",
  "operations": [
    { "operationId": "listPets", "method": "GET", "path": "/pets" },
    { "method": "GET", "path": "/pets/{petId}" }
  ]
}

format is "OpenAPI 3.0" or "OpenAPI 2.0 (Swagger)". title is the document's info.title and "" when it states none - never a substitute name, because the caller asked what the document calls itself. operations are in document order, in the shape a request's spec_operation records, operationId absent rather than "" for an operation that declares none - the same rows POST /specs/match takes.

This is the reader every spec write uses (issue #853), which is the point of the route: the identities answered here are the identities POST /specs/bind will derive from the same bytes. Before issue #869 the app read the picked document itself, so a document the two sides read differently previewed one pairing and committed another.

Errors: 400 for a missing or empty content, bytes over maxSpecDocumentBytes (the same cap a store applies, so a preview cannot succeed where the bind will fail), bytes that cannot be read as JSON or YAML, a document declaring more than 2000 operations, and a readable file that is not an OpenAPI document - the last named as what is missing rather than answered as a contract declaring nothing.

POST /specs/diff

What a re-fetched document would change about the collection bound to it - the read half of a sync (issue #654's comparison, moved engine-side by #854). The app's Sync section is this call; an agent asking "has this contract drifted, and where" is the same one.

Reads only. No document is stored, no binding moved, no request stamped, so a caller may ask about a document it has not decided to apply. Applying is POST /specs/sync, which re-reads everything rather than being handed this answer - the same split POST /specs/match and POST /specs/bind follow.

Request:

{
  "collectionId": "col_9a1f...",
  "spec": { "content": "{\"openapi\": \"3.0.0\", ...}" }
}

Neither the requests nor the bound document are sent. The engine walks the subtree of collectionId itself (an import files its requests under one sub-collection per tag) and reads the bound document from the collection's own binding: the three-way rule below is only worth anything if the "previous" side is the bytes actually stored, and a caller that could supply them could turn its own edits into the document's - by accident, with a stale copy.

Response:

{
  "identical": false,
  "added": [
    {
      "operation": { "operationId": "listOwners", "method": "GET", "path": "/owners" },
      "folder": "owners",
      "safe": true,
      "draft": {
        "name": "List owners",
        "description": "",
        "method": "GET",
        "url": "{{baseUrl}}/owners",
        "params": [],
        "headers": [],
        "body": { "mode": "none" },
        "examples": []
      }
    }
  ],
  "removed": [
    {
      "requestId": "req_7b21...",
      "name": "Delete a pet",
      "operation": { "operationId": "deletePet", "method": "DELETE", "path": "/pets/{petId}" },
      "safe": false
    }
  ],
  "changed": [
    {
      "requestId": "req_4c8e...",
      "name": "List pets",
      "boundOperation": { "operationId": "listPets", "method": "GET", "path": "/pets" },
      "operation": { "operationId": "listPets", "method": "GET", "path": "/pets" },
      "matchedBy": "operationId",
      "renamed": false,
      "previousUnknown": false,
      "safe": true,
      "safeFields": ["name"],
      "fields": [
        {
          "field": "name",
          "current": "List pets",
          "next": "List all the pets",
          "userTouched": false
        }
      ],
      "draft": { "name": "List all the pets", "...": "as above" }
    }
  ],
  "unchanged": 12,
  "unmapped": 1
}

identical is decided on the stored bytes - the ones spec_documents.hash is over - rather than on a hash the caller computed, and is the "up to date" answer. The buckets are still reported when it is true, empty.

draft is the request an import of the new document would build: the values behind every next (which is truncated for display), plus the operation's documented responses as examples, so a POST /specs/sync payload is built by choosing which of these fields to send rather than by re-reading the document. The examples here are reported rather than returned to the engine: since issue

869 a sync says whether to refresh a request's imported examples and the rows

come off the document it stores, so this is what an apply would write, shown. unmapped counts requests carrying no operation at all - not part of the comparison, but stated, because a sync that silently ignores half a collection is one nobody can read.

safe is what an apply with no ticks would do to that entry (issue #871), and safeFields is which of a changed request's fields it would write - empty for a pure rename, which is a real selection rather than an absence. It is core::safe_spec_apply: every operation the document adds, every field it moved that nobody here had edited, no deletions, and a request whose bound document could not be read left alone whole. Reported rather than left to the caller because the rules are the ones whose silent failure costs somebody their work, and because the same function decides what POST /specs/sync's policy applies - so the app's pre-ticked boxes and a caller that ticks nothing are one answer rather than two that agree today. safe on a removal is always false and is carried anyway, so that a reader marking the bucket reads the rule instead of writing it.

How it compares. An operation is followed by its operationId first and by method + path shape second (the same flattening POST /specs/match binds with), so a path moved under a stable id and an id moved under a stable path both stay one operation, while both moving at once is disclosed as a removal plus an addition rather than guessed at. An id two requests claim identifies neither and is skipped; an id whose entry contradicts the endpoint the request records loses to an exact match on that endpoint. renamed says the identity itself moved, so an apply records the new one.

userTouched is three-way: the field is flagged when what the request holds is neither what the new document produces nor what the bound one did - the only evidence that a person put it there, and the flag an apply may not overwrite silently. previousUnknown says the bound document does not declare this operation, so no such claim can be made about it at all, and every userTouched on that request is false.

Response examples are deliberately not compared. The rule that governs them (origin="import" is replaced, origin="user" survives) only means anything at apply time, so they ride on draft rather than appearing as a difference nothing acts on.

Errors: 400 for a missing or empty collectionId, a missing or empty spec.content, a document larger than maxSpecDocumentBytes, a document that will not read as JSON or YAML (the message names where it broke), or a collection that binds nothing - there is nothing to compare against, and binding is POST /specs/bind. 404 when the collection does not exist. 409 when its binding names a document the store no longer holds, which is a broken binding rather than a bad request.

POST /specs/bind

Bind a collection to an OpenAPI document, in one transaction: the document is stored, the collection's binding moves to it, and every request in the collection's subtree is stamped with the operation it matched or has its stamp cleared. Nothing is created or deleted - that is what a sync is for.

The caller sends a document, never a pairing. The engine reads the bytes it is about to store, derives the operations index from them, and matches the subtree with core::match_operations - the same rule POST /specs/match previews with, over the same subtree walk. A pairing worked out by the caller would be a second opinion about what the document declares, and an agent over MCP has bytes and no OpenAPI reader at all.

Request:

{
  "collectionId": "col_9a1f...",
  "spec": {
    "content": "openapi: 3.0.0\n...",
    "sourceUrl": "https://api.example.com/openapi.yaml"
  }
}

spec.content is the document verbatim, JSON or YAML, capped by maxSpecDocumentBytes. sourceUrl is optional and null or absent means the document did not come from a URL. id, hash, fetchedAt, operations and responseSchemas are engine-computed and a 400 if sent, the same rule POST /specs applies.

Response:

{
  "specId": "spec_2b74...",
  "specHash": "8f3c...",
  "syncedAt": 1755000100000,
  "stamped": 12,
  "cleared": 2,
  "unmatchedRequests": ["req_7b21..."],
  "unmatchedOperations": [{ "method": "GET", "path": "/pets/{petId}" }]
}

unmatchedRequests and unmatchedOperations carry the same shapes POST /specs/match answers with, so a caller reads one shape for the preview and the commit.

Stamping goes both ways (issue #718). After a bind, a request's spec_operation is the operation it matched in the bound document, or nothing: cleared counts the requests whose identity was removed because this document does not account for it. That half is not a list the caller states - it is the other side of the same walk - because a bind that wrote only the matches left every non-matcher carrying identity from the previous document, and coverage resolves a stamp by operationId first, so such a stamp claims whichever operation of the new document shares the id rather than going unread.

Nothing outside the subtree is touched, the rule POST /specs/sync follows: a request under another collection keeps its own stamp even when it would have matched.

Errors: 400 for a malformed body, an engine-owned field, a document over the cap, or one that cannot be read as JSON or YAML - and in that last case nothing is written, so the collection stays bound to whatever it was bound to. 404 when the collection does not exist. 409 when a row moved under the write.

Unbinding is not here: it is PUT /collections/:id with "openapi": null, which writes one row, leaves every stamp in place - so unbind-then-rebind of the same document is lossless - and has no document to read.

POST /specs/export

A collection back out as an OpenAPI document - its own bound document updated, or a skeleton describing its requests when it binds none. Which of the two runs is a fact about the collection rather than a parameter.

Reads only. Nothing is stored and the collection is left exactly as it is; a POST because the answer is a document rather than a resource, and because the body carries the format.

Request:

{
  "collectionId": "col_9a1f...",   // Required
  "format": "json"                 // Optional - "json" (default) or "yaml"
}

Neither the requests nor the document are sent. The engine reads the whole subtree of collectionId (an OpenAPI import binds the root and files every request under one sub-collection per tag), each request's stored examples, and the bound document itself - stopping at any collection bound to a different document. That boundary is not a filter: the refused collection takes its own descendants with it, because its requests carry another document's operation stamps and operationIds are names generators hand out in every document (listUsers, GET /users), so letting them through would have them claim these operations and rewrite them. A descendant bound to the same document is part of the export, because its requests describe the very operations being patched.

Response:

{
  "text": "{\n  \"openapi\": \"3.0.3\",\n ...",
  "fileName": "petstore.openapi.json",
  "notes": {
    "direction": "document",        // or "skeleton"
    "dialect": "OpenAPI 3.0.3",
    "requestsExported": 12,
    "requestsWithoutOperation": 1,
    "operationsNotInDocument": 0,
    "operationsRemoved": 2,
    "requestsWithoutPath": 0,
    "duplicateOperations": 0,
    "examplesWritten": 4,
    "examplesWithoutMediaType": 1,
    "examplesTruncated": 0,
    "examplesAlreadyDeclared": 3,
    "examplesSampledAtImport": 2,
    "sharedParametersLeft": 1,
    "referencedResponsesLeft": 1,
    "bodiesNotWritten": 2,
    "rowsNotDeclared": 1,
    "operationsEdited": 0,
    "vocabularyNotWritten": false,
    "authDropped": 0,
    "scriptsDropped": 0,
    "variablesDropped": 0,
    "foldersFlattened": 0,
    "bodiesDropped": 0,
    "formValuesDropped": 0,
    "settingsDropped": 0,
    "exampleHeadersDropped": 0,
    "duplicateParameterRowsDropped": 0
  }
}

Every count is present, zeros included: "0 requests with no operation" is how a bound export states that it carried everything, and a body that omitted its zeros would read as complete whether or not it was.

A bound export patches the stored bytes, never rebuilds them. Operations no request claims are removed (and a path left with no operations goes with them), a declared parameter whose request row carries a value gets it as example, and stored examples become response examples - one as example, several as a named examples map. Everything else - info, tags, vendor extensions, security, components nothing references - is carried through by simply not being visited, and the dialect is left as it was.

It adds, and never removes what the document declares (#1442). A media object already answering with an examples map keeps every entry it had and gains one; a single example is replaced by the one value that would replace it, and moves into the map under the key example when a second example has to go somewhere. A response that is a $ref is left bare and counted as referencedResponsesLeft: a Reference Object admits no siblings in 3.0, so a content written beside it is ignored by conformant readers and rejects the document at a validator, and the component it names is shared with every operation that references it. An example whose value the document already declares is counted as examplesAlreadyDeclared and written nowhere, and an imported example (origin: import) for a media object that declares no example at all was sampled off that response's schema at import, so it is counted as examplesSampledAtImport rather than written back as though the API had stated it; where the document declares no such response either, the status is not documented from it at all, because putting back a response the contract dropped is not this export's to do. Values are compared as values, so an example whose members are stored in another order than the document writes them is the same example. An export of a spec-origin collection nobody edited is therefore the document it was bound to, structurally unchanged.

The edits it cannot express are counted, not silent. The bound direction writes parameters and examples: a request body is bodiesNotWritten, a Params or Headers row the operation declares no parameter for is rowsNotDeclared (Authorization and Content-Type excepted - OpenAPI states them as security and as the body's media type), and a request whose method or path no longer matches the operation it is stamped as is operationsEdited - its values still land, in the operation the document declares. A parameter the operation declares by $ref, or one the Path Item declares for every method under it, is declared: its name is read through the reference, so a row that has a home is never counted as one the document has no place for, and sharedParametersLeft is what says its value was not written.

A Swagger 2.0 document is the one partial case, reported as vocabularyNotWritten: operations nothing claims are still removed, but nothing is written into an operation, because 2.0 states parameters and examples in a vocabulary Vayu does not write.

A skeleton invents nothing. {{variable}} tokens are written as they stand in paths (resolving them would export one machine's environment as though the contract named it); a {{baseUrl}} server gets a real default when the collection's own baseUrl variable has a value - written as the single-brace {baseUrl} OpenAPI's Server Variable syntax expects, with that value as its default - and the bare double-brace token otherwise, since an undeclared single-brace variable is invalid OpenAPI. A path segment that is exactly one token becomes the OpenAPI {petId} it came from, every Params and Headers row is declared without a required it was never given but carries its toggle explicitly as x-vayu-enabled (neither a value nor its absence says whether a row is on), and a body or response is described only where there is a stored example to read a shape off - carrying a description that says the shape is derived. A JSON body holding a {{variable}} token is not valid JSON on its own, so it is written as the text it is and read back the same way, byte for byte, rather than re-quoted into a JSON string.

It carries what OpenAPI can name, for the modes it has names for (#1441). A folder becomes a tag named by its full path, declared once at the document root and on every operation under it - a folder nested more than one level flattens to a single tag, counted as foldersFlattened, since folderStrategy: tags regroups by tag name flat. Auth becomes components.securitySchemes plus a security requirement - basic and bearer as http, an API key as apiKey, OAuth 2 as oauth2 with the flow the request uses - at the document root for the collection's own auth and on the operation only where a request's differs from it; an explicit no-auth request is security: [], one left to inherit gets no override, and a mode with no OpenAPI name (digest, aws, ntlm, an unrecognized one) is authDropped. What is left with nowhere to go is counted rather than guessed at: variablesDropped (a collection variable besides baseUrl), bodiesDropped (a body in a mode this direction has no media type for - GraphQL today), formValuesDropped (a form body's field values, its names still declared), settingsDropped (a non-default redirect, TLS, HTTP-version or streaming setting), and exampleHeadersDropped (a stored example's header besides Content-Type). A Params or Headers row sharing a key and location with an earlier row would produce two Parameter Objects for the same name+location, which OpenAPI forbids - only the first is written and the rest are counted as duplicateParameterRowsDropped. scriptsDropped stays in the response - a zero is a statement too - but now always reads 0: a request's or the collection's elements (scripts included) round-trip through x-vayu-elements instead (issue #1518, below), so nothing about them is dropped any more.

x-vayu-elements carries a request's or the collection's whole elements array verbatim, the same vendor-extension convention x-vayu-enabled already established - never a standard OpenAPI field, so writing it is never "rewriting the user's contract". A skeleton export writes it on every operation and at the document root whenever the corresponding elements array is non-empty; a bound export writes it on an operation the document already declares (gated on dialect.writable, the same gate every other bound field this reference names above relies on - nothing is written into a Swagger 2.0 document's operations at all). The importer reads it back verbatim, validated against the live element registry - a document hand-edited into an invalid array is not applied and is counted under elements_invalid in meta.skipped (see POST /import/parse) rather than reaching the stored request. Not read on the sync/diff path (POST /specs/diff / POST /specs/sync): DraftRequest, what that path compares a stored request against, deliberately excludes scripts and auth so a re-sync never silently overwrites what a user edited locally, and an element can carry a script.

x-vayu-mock carries a request's mockResponseMode (issue #1649) the same way x-vayu-elements carries its elements - written only when the mode is not "first", the default every unconfigured request already has, so an operation with no mock opinion gains no key at all. {"mode": "random"} needs no target; {"mode": "fixed", "example": "<key>"} names the examples map key (or the doubled key a name collision suffixed, add_named_examples's usual rule) the chosen example was written under, resolved by value rather than assumed - a target this export could not itself write (a truncated body, no recorded media type, a response the document does not declare) is the same "nothing to name" case as the mode staying "first", and gets no key either. Gated on dialect.writable exactly like x-vayu-elements: nothing is written into a Swagger 2.0 document's operations at all. The importer reads it back next to x-vayu-elements, matching example against the imported example that carries the same OpenAPI examples map key (the engine-side-only specExampleKey provenance field, issue #1457) and setting mockResponseMode / mockExampleId on the imported request; a key the document no longer has an example for degrades the way elements_invalid does - counted as mock_example_missing in meta.skipped and left on pick_example's own "first" default rather than naming a target that is not there. Not read on the sync/diff path, for the same reason x-vayu-elements is not.

Errors: 400 for a missing or empty collectionId, or a format other than json/yaml. 404 when the collection does not exist. 409 when the collection's binding names a document that is not stored, or when the stored bytes will not read as an OpenAPI object - the export refuses rather than falling back to a skeleton, which would silently replace the document the caller believes they are updating with one that drops everything Vayu does not model.

POST /specs/sync

Apply a re-fetched document to the collection bound to it, in one transaction: the document is stored, the binding moves to it, and the requests the caller selected are created, updated and deleted together. Half of that landing would leave a collection bound to a document its requests do not reflect, which is the state the binding exists to make impossible - so it is refused structurally rather than reported afterwards.

Deliberately not part of POST /import/apply: that route only ever creates, which is what lets it own every id and validate a payload with nothing stored behind it. A sync updates and deletes rows that already exist.

Request:

{
  "collectionId": "col_9a1f...",                       // Required; must be bound to a spec
  "spec": {
    "content": "{\"openapi\":\"3.1.0\", ...}",           // Required, non-empty, at most maxSpecDocumentBytes
    "sourceUrl": "https://api.example.com/openapi.json"  // Optional; null for a file or a paste
  },                                                   // Both indexes are derived - see POST /specs
  "policy": "safe",                                    // Optional - apply the safe ticks and state no rows.
                                                       // Mutually exclusive with the four sections below.
  "collections": [                                     // Optional - tag folders to create
    {"tempId": "t_col", "name": "pets", "parentId": "col_9a1f..."}
  ],
  "create": [                                          // Optional - operations the document added
    {"tempId": "t_req", "collectionTempId": "t_col", "name": "Get a pet",
     "method": "GET", "url": "https://api.example.com/pets/{{petId}}",
     "specOperation": {"operationId": "getPet", "method": "GET", "path": "/pets/{petId}"}}
  ],
  "update": [                                          // Optional - merge-patch by id
    {"id": "req_1234...", "name": "List pets", "specOperation": {...}, "examples": true}
  ],
  "delete": ["req_5678..."]                            // Optional - request ids
}

Response:

{
  "idMap": {"t_col": "col_bb21...", "t_req": "req_cc34..."},
  "specId": "spec_dd45...",
  "specHash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
  "syncedAt": 1730000000000,
  "created": 1, "updated": 1, "deleted": 1,
  "skipped": {"requests": 2, "fields": 3, "deletions": 1}  // Only for a `policy` call - see below
}

policy is the alternative to stating rows (issue #871). "safe" - the only one - is core::safe_spec_apply, the same answer POST /specs/diff reports per entry as safe / safeFields: every operation the document adds, every field it moved that nobody here had edited, no deletions, and a request whose bound document could not be read left alone whole. The engine works out the rows, mints the tag folders an added operation needs (matching an existing direct child by name, creating one at most once per call), and writes them through everything below - the same validation, the same id minting, the same transaction. It exists because those rules used to live in the renderer alone, which put applying a drift out of reach of every caller that is not the Spec tab: electron/ may not import src/, so an MCP sync_spec would have needed a copy of the one judgement whose silent failure destroys a person's work.

Sending policy together with collections, create, update or delete is a 400: there is no reading of "the safe ticks, plus these" that is not a guess. A caller that wants anything the policy declines - a deletion, a field somebody edited - states the rows itself, which is what the app does the moment a user changes a tick.

skipped comes back only for a policy call and counts what it refused: requests left untouched entirely, fields the document moved that were not written (whether their request was skipped whole or applied around them), and deletions not made. An explicit payload gets no such key - nothing was declined, because the caller chose the rows. It is reported for the reason the diff bounds its buckets rather than dropping entries: a call that answered only with what it wrote would read as "applied the drift", and the part it did not apply is exactly the part somebody has to decide about.

Five rules the payload cannot opt out of:

  • A delete here is permanent - it does not go to the Trash (issues #988, #1046). Every other delete in the engine is soft: the row is stamped and restorable. A sync is not a person removing a request, it is a reconciliation to a document, and the two differ in where the decision is made - POST /specs/diff reports every removal before anything is written, the app renders them as ticks the user unticks one by one, and policy: "safe" refuses deletions outright, so a deletion here is one a caller stated after being shown it. Leaving those rows stamped instead would put the operations a document no longer declares back in the trash on every sync, where restoring one re-creates a request the document cannot explain. The rows and the examples they own are removed in the sync's own transaction. A caller that wants the deletions recoverable omits them from the payload and issues DELETE /requests/:id per row, which is soft.
  • The bound subtree is the boundary. Every request an update or a delete names, and every collection a created request lands in, must be the collection being synced or one beneath it. Anything else is a 400 naming the item - without this the route would be a way to delete any row by id.
  • The engine mints every id, as /import/apply does: new rows are named by tempId and translated through idMap, and a body id is a 400.
  • examples is a decision, and the rows are the document's (issue #869). true on an update refreshes that request's imported examples from the document this call is storing; absent (or false) leaves every example alone. A created request always gets the responses its operation documents, so examples on a create item is a 400 - as is a list on an update, which is what a caller sent before this and what let a payload write an example for a response no document describes. A refresh whose request records no operation of the document being stored is a 400 too, rather than a silent no-op. Rows with origin: "user" (issue #588) always survive; the replacements take the block the replaced rows occupied, so a saved example never loses its position - "the first example" is what a mock server answers with. An operation whose documented responses were removed refreshes to no rows, which is how the last import's examples go. An example whose status the user deleted is not written back either (issue #722) - the delete left a tombstone and the refresh skips that status, so a sync of any field cannot undo it. The identity is the status, not the name, because a name carries the document's response description and moves when the document rewords it.
  • A request cannot be moved here. collectionId inside an update item is a 400; use PUT /requests/:id.

Errors: 404 when the collection does not exist. 400 when it is bound to no spec, when policy is not "safe" or arrives beside a row section, when a section is not an array, when a payload item is malformed (with error.item naming the tempId or the request id, as /import/apply does), or when the document is over maxSpecDocumentBytes - the same helper POST /specs uses. 409 when an update names a request that no longer exists: nothing about the payload is wrong, the ground the diff was computed against has moved, and re-checking is what the client should do. A delete naming a request that is already gone is not an error - that is the state the caller asked for. Nothing is written unless all of it is.

Reorder

POST /reorder

Reposition collections and requests in one atomic batch - the write path behind a drag-and-drop reorder or a cross-folder move. One drop is one call, one lock scope and one transaction: the batch validates, stages and commits under a single acquisition of the engine's database lock, so nothing partial survives a rejection and no concurrent write - a create computing its append slot, a conflicting batch, a cascade delete - can land between what this batch checked and what it wrote.

Request:

{
  "normalize": [
    {"type": "request", "collectionId": "col_1234567890"}
  ],
  "moves": [
    {"type": "request",    "id": "req_1", "order": 0, "collectionId": "col_2"},
    {"type": "collection", "id": "col_3", "order": 1, "parentId": null}
  ]
}

Both arrays are optional (absent or null means none); an empty batch is a 200 that writes nothing.

moves - each entry names one row and the position it takes:

Field Rule
type "collection" or "request"; anything else is a 400
id Non-empty string naming a stored row of that type
order Required, a non-negative integer. Not a float, not negative - this endpoint writes dense positions, and either would be a silently truncated or unreachable slot
parentId (collection) Absent keeps the current parent; null moves to the root; a string moves under that collection, which must exist
collectionId (request) Absent keeps the current owner; a string moves to that collection, which must exist

A row may appear in moves at most once - two positions for one row is a 400 naming it, not a last-writer-wins accident of iteration order.

normalize - each entry names a scope whose children are renumbered dense 0..n-1 in the pinned display order before any move applies. A collection scope states parentId (null for the root collections) and a request scope states collectionId; the named collection must exist, and for a collection scope parentId must be stated rather than omitted, so a renumber can never land on a scope the caller did not mean.

Normalization exists for the first drop into a collection whose rows predate explicit orders: every row sits at 0, so its displayed position lives only in the tiebreak and there are no slots to shift into. Materializing that order in the same batch is what keeps the other siblings from appearing to jump. It is idempotent - a scope already dense writes nothing at all.

Where a row is named by both lists, the move wins.

Validation is complete before the first write. Entry shapes, the existence of every named row and owner, and the acyclicity of the post-move collection graph are all checked first; a failure is a 400 naming the offending row with nothing written. The cycle check reading the post-move shape is what makes two reparents that each look legal alone (A under B and B under A) a deterministic rejection rather than a race. The same pair sent as two PUT /collections/:id calls is caught as well - each update holds its read to its write - but one at a time, so the first reparent is already committed when the second is refused; the batch refuses both and writes nothing.

That rejection is deterministic because the check and the commit share one lock scope: two such batches arriving concurrently are serialized whole, and the second revalidates against the first's committed graph rather than against the shape both of them read. The rows are written as updates, never inserts, so a batch whose row was deleted after it staged fails with a 409 naming the row and writes nothing at all - it never re-creates the deleted row.

Response: the rows as written, in the same serialized shape a list entry carries - not an acknowledgement. A client that drew the drop optimistically settles its caches on these, so a normalization the engine performed is visible without waiting for a refetch.

{
  "collections": [],
  "requests": [
    {"id": "req_1", "collectionId": "col_2", "order": 0, "name": "Get users", "...": "..."}
  ]
}

Errors: 400 for any malformed entry, a row or owner that does not exist, a duplicate move, a cycle, or a batch over 10000 entries. 409 if a staged row is gone by the time the batch commits. In every case nothing at all was written. Body that is not JSON or not an object is also a 400.

Import

POST /import/fetch

Fetch a remote collection or spec by URL, server-side, so the app can import a resource that browser CORS would otherwise block. The engine proxies the GET via libcurl and returns the raw body and content type.

Request:

{ "url": "https://example.com/collection.json", "maxBytes": 10485760 }

The url must be a string starting with http:// or https://.

maxBytes is the caller's bound on the response, and the caller states it because this route is one proxy for every import format - a Postman or Insomnia export rides it exactly as an OpenAPI document does. Bounding it by maxSpecDocumentBytes would refuse a collection that imports today with a message naming a setting that governs nothing about it, so the callers that are fetching a spec ($ref bundling and spec re-fetch) pass that live cap themselves and the rest pass nothing.

  • Absent or null means the engine's transport ceiling, 256 MiB. So does a larger value: a bound over the ceiling is clamped to it rather than refused, because maxSpecDocumentBytes can be raised to 100 MiB and an import the setting allows must not be turned into a 400.
  • Anything that is not a positive integer - 0, a negative, a string, a fraction - is a 400. It is not rounded into a bound the caller did not ask for.
  • The bound is on what is read, not on what is returned: the transfer is cut off as soon as the body grows past it, whether the server declared a length or not, so nothing buffers the whole document first. A declared length is what the refusal names as the size.

A fetch is bounded by a stall, not by a total (issue #882). This route used to inherit the 30-second timeout_ms every request carries, which bounds a download's size rather than its health: 10 MB needed better than 340 KB/s merely to arrive, and the failure read Operation timed out after 30001 milliseconds with 4177920 out of 6296254 bytes received for a transfer that had never once stopped. It is now abandoned only after constants::import_fetch::STALL_TIMEOUT_MS below STALL_FLOOR_BYTES_PER_SEC - so a slow link finishes, slowly, and a dead one still ends. What bounds a transfer that never ends at all is maxBytes above, which is the bound that was always meant. Both forms of the route get this: a document must not arrive on one and time out on the other.

Response: 200

{
  "content": "...raw response body...",
  "contentType": "application/json"
}

contentType echoes the fetched response's Content-Type header, defaulting to application/octet-stream when absent. The response JSON is serialized with invalid UTF-8 replaced rather than throwing, so binary or malformed content can never turn into a 500.

Errors: - 400 Invalid JSON body - the request body did not parse. - 400 Invalid URL - url is missing, not a string, or does not start with http:// / https://. - 400 Invalid 'maxBytes': must be a positive integer. - 413 Refused to fetch: <detail> - the response was over the bound in force. The detail names the bound that was applied (the clamped one, not the one asked for) and the size when the upstream declared one. - 502 Failed to fetch: <detail> - the upstream request failed (connection error, transport failure).

With Accept: text/event-stream the same fetch answers as a stream (issue #882), reporting the download as it arrives. An 8 MB spec behind a URL is otherwise a client with nothing to draw: the buffered form above cannot say a word until libcurl holds the entire body, and the wait is on the upstream download, so no amount of streaming between engine and client would help.

Three events, and the last one is always terminal:

Event Data Meaning
progress {"received": 262144, "total": 8388608} Bytes buffered so far. total is the upstream's Content-Length and is null when it declared none - a chunked response has no denominator, so a client shows bytes received rather than a percentage of a number nobody stated.
result {"content": "...", "contentType": "application/json"} Exactly what the buffered form returns.
error {"status": 413, "error": {"code": "error", "message": "..."}} The failure the buffered form would have answered with.
  • A malformed request is still the 400 above, on both forms: it is decided before any of the response has gone out, while a status is still available to say it with.
  • A failed fetch is an error event, not a status. The response headers left before the download began, so the 200 is already spent - which is why that event carries the numeric status itself. The standard error body could not: its code is a slug, and 413 and 404 both slug to plain error.
  • Progress is throttled to at most one frame per 256 KiB or 100 ms (constants::import_fetch::PROGRESS_EVERY_BYTES / _MS). libcurl reports every ~16 KiB write, which for a 10 MB document is ~640 frames for a bar with a few hundred pixels to cross.
  • Closing the stream stops the download. The engine abandons the transfer as soon as a write to the SSE sink fails, rather than reading the remaining megabytes for a client that has gone.
  • Every caller that sends no Accept gets the buffered JSON unchanged, which is what $ref bundling, spec re-fetch and the MCP tools do.

POST /import/parse

Parse a raw import document into the tree POST /import/apply persists (issue #877). Reads only - nothing is stored, so a preview costs no write.

Every format Vayu accepts, detected by content, in the order the app's dispatcher used to run its parsers in - so a document carrying two formats' keys is claimed by the same one it always was:

Claimed by meta.format
info.schema naming v2.1.0 Postman Collection v2.1
info.schema naming v2.0.0, or info + item[] with no schema Postman Collection v2.0
_postman_variable_scope: "environment" + values[] Postman Environment
_postman_variable_scope: "globals" + values[] Postman Globals
_type: "export" + __export_format: 4 Insomnia Export v4
openapi starting 3. OpenAPI 3.0
swagger being 2.0 (string or number) OpenAPI 2.0 (Swagger)
the raw text contains <jmeterTestPlan JMeter <version> Test Plan

The bytes are read once, JSON first and YAML second, through the same core::read_document behind POST /specs and POST /specs/describe. This is the only parser: the renderer holds none, which is what makes "exactly one reader has an opinion about a document" true rather than nearly true. JMeter's .jmx is the one exception to "read once through read_document": it is XML, checked on the raw text (issue #1518, is_jmeter_document) before that reader ever runs, since XML fails both of its formats the same way genuinely unrecognised bytes do - see docs/app/import-collections/jmeter.md for the class mapping.

Request:

{
  "content": "{\"info\": {...}, \"item\": [...]}",
  "importEnvironments": true,
  "importScripts": true,
  "fileName": "collection.json",
  "sourceUrl": "https://acme.dev/openapi.json",
  "unresolvedRefs": 0
}

Field Type Notes
content string, required The document, verbatim. An OpenAPI import stores exactly these bytes as its spec document, so it is never re-serialized.
importEnvironments bool, default true Off, the environments and global variables a document carries are not built and the counts report 0 - gated at parse time, so a preview shows what will actually be created.
importScripts bool, default true Off, every script imports empty.
fileName string Display only; echoed on meta.fileName. Never stored.
sourceUrl string Recorded on a stored OpenAPI document (so a later sync knows what to re-fetch) and used to resolve a relative servers[0].url.
unresolvedRefs int ≄ 0 External $refs a bundling pass could not reach, counted into meta.skipped as external_ref. Nothing here follows a ref.

Response: 200 - the whole import tree.

{
  "collections": [ { "name": "Sample API", "description": "", "variables": {}, "auth": {"mode":"none"},
                     "elements": [], "children": [], "requests": [] } ],
  "environments": [],
  "globals": {},
  "clientCertificates": [],
  "meta": {
    "format": "Postman Collection v2.1", "requestCount": 2, "folderCount": 1,
    "environmentCount": 0, "globalCount": 0, "exampleCount": 0,
    "skipped": [ { "kind": "websocket", "count": 1 } ],
    "nonExecutableAuth": 0, "unattachedFileParts": 0
  }
}

clientCertificates is present, and only ever non-empty, from a Postman parse (issue #1656): a client_certificates registry candidate resolved from a request's own certificate, in the same field shape POST /client-certificates takes (host, port?, certPath, keyPath?, certFormat?, passphrase?). A candidate is resolved only when it would pass that route's own check - a readable PEM pair or PKCS#12 file, a literal (not {{var}}) host, no earlier request in this parse already claiming a different certificate for the same (host, port) - so resolving one is the common case only when the collection's certificate paths are reachable from this machine, which a real-world Postman export's rarely are (its paths name files on the exporting machine). Anything that does not resolve counts into meta.skipped as certificate instead of appearing here. Every other format sends no such key. See docs/app/import-collections/postman.md.

meta.skipped is what the document declared and Vayu cannot represent, counted per kind - websocket, grpc, api_spec, unit_test, file_body, malformed_item, unsupported_method, malformed_spec, example_no_status, default_response, external_ref, duplicate_operation_id, cookie_param, unmapped_body, unresolved_base_url, unsupported_auth, path_variables, url_without_raw, variable_metadata, elements_invalid. Not every kind is a loss: default_response, path_variables and url_without_raw count a mapping the import made rather than something it dropped (see docs/app/import-collections/postman.md). An import that loses something and says nothing is the defect this list exists to prevent, so a format with nothing to report answers [] rather than omitting the field. A JMeter import's kind is not limited to this list (issue #1518): a .jmx class this parser has no element-kind mapping for is counted under its own literal class name (ThreadGroup, CookieManager, ...), since JMeter's own class list is open-ended - ImportTally::items() emits every kind it was given a count for, the enumerable ones first in the fixed order above, anything else after in first-encountered order. meta.folderStrategy (tags / paths / mixed) is present only when an OpenAPI import built folders, since a document that declares no operation tags gets a tree it never spelled out.

Errors: - 400 Invalid 'content': must be the document's text, and 400 Invalid 'content': an empty document is not an import. - 400 Unrecognised format - readable, and no format claims it. Kept as its own sentence: "Vayu does not read this kind of file" and "this file is broken" are different answers. - 400 Could not read the document: <detail> - neither JSON nor YAML, with the line named. - 400 Malformed Insomnia export: <field> must be an array - the one format whose export is a flat resource list, so a broken one leaves nothing to walk. - 400 Malformed JMeter test plan: <detail> - XML pugixml could not parse, or well-formed XML with no <jmeterTestPlan> root. - 400 Invalid 'importScripts': must be a boolean (and the same for importEnvironments, fileName, sourceUrl, unresolvedRefs). - 413 Import document is N bytes, over the limit of M (raise the 'maxSpecDocumentBytes' setting to allow more).

POST /import

Parse a document and persist it, in one call - POST /import/parse, the flattening into temp-id'd sections, and POST /import/apply, for a caller with no preview to show (issue #877). This is what the MCP import_document tool wraps. The app deliberately does not use it: a person picks which files of a batch to import and toggles two options between the parse and the apply.

Request: the same body as POST /import/parse.

Response: 200

{
  "idMap": { "c1": "col_<uuid>", "r1": "req_<uuid>" },
  "meta": { "format": "Postman Collection v2.1", "requestCount": 2, "...": "..." },
  "collections": 2, "requests": 2, "environments": 0, "globals": 0
}

Atomicity: the tree is one POST /import/apply transaction, so a refused document creates nothing. The globals a Postman globals export carries are the one write outside it, and they run last and merge: POST /globals replaces the whole set, so running it in front of a write that can still fail would leave a user's globals half-rewritten by an import that then failed. On a key collision the imported value wins - the caller asked for this file's variables, and skipping them would be a silent no-op. A Postman import's clientCertificates candidates (issue #1656) are also outside the tree's transaction, applied best-effort inside POST /import/apply itself after it commits; a candidate POST /import/apply cannot register is skipped, never fails the call.

Errors: every error of POST /import/parse, plus every error of POST /import/apply (including the per-item 400s naming a tempId).

POST /import/document

Read a document's bytes (JSON or YAML) into a JSON DOM, through the engine's one reader. Stores nothing and interprets nothing: this is what the bytes are, where POST /specs/describe says what they declare.

One caller, and it is the reason the route exists: the app's ref-bundler.ts walks a multi-file OpenAPI document to inline the files it references before anything is parsed or stored, and finding and rewriting those $refs needs a tree. A YAML reader in the renderer to get one was the last thing keeping a second parser in app/src after issue #877.

Request: {"content": "openapi: 3.0.0\npaths: {}\n"}

Response: 200 {"document": { "openapi": "3.0.0", "paths": {} }} - in the document's own key order, which is a promise a JavaScript object cannot keep (it orders integer-like keys numerically ahead of the rest).

Errors: - 400 Invalid 'content': must be the document's text. - 400 Invalid 'content': <detail> - neither JSON nor YAML. - 413 Document is N bytes, over the limit of M (raise the 'maxSpecDocumentBytes' setting to allow more).

POST /import/apply

Persist an entire parsed import - collections, their requests, environments, and the OpenAPI documents they bind - in one atomic call. Items reference each other by opaque temp ids the client invents; the engine generates every real id via generate_id and returns the translation in idMap. A fifth section, clientCertificates (issue #1656), is not part of that atomic tree or its temp-id namespace - see its own bullet below.

This is what replaced ~500 sequential POST /collections + POST /requests calls for a 500-request import, and with it the only reason those endpoints ever accepted a client-supplied id - which they no longer do (see The engine owns every id).

Request:

{
  "specs": [
    { "tempId": "s1", "content": "{\"openapi\":\"3.1.0\", ...}",
      "sourceUrl": "https://api.example.com/openapi.json" }
  ],
  "collections": [
    { "tempId": "c1", "parentTempId": null, "name": "My API", "order": 0,
      "variables": {}, "auth": {"mode":"none"},
      "openapi": {"specTempId": "s1"},
      "elements": [] },
    { "tempId": "c2", "parentTempId": "c1", "name": "Users", "order": 0 }
  ],
  "requests": [
    { "tempId": "r1", "collectionTempId": "c2", "name": "List users",
      "method": "GET", "url": "https://api.example.com/users",
      "params": [], "headers": [], "body": {"mode":"none"}, "bodyType": "none",
      "auth": {"mode":"inherit"}, "order": 0,
      "examples": [
        { "name": "200 - A user", "status": 200, "headers": [], "body": "{}",
          "contentType": "application/json" }
      ] }
  ],
  "environments": [
    { "tempId": "e1", "name": "Prod", "variables": {} }
  ],
  "clientCertificates": [
    { "host": "api.example.com", "port": 8443, "certPath": "/home/alice/certs/client.pem",
      "keyPath": "/home/alice/certs/client-key.pem", "certFormat": "pem" }
  ]
}

  • All five sections are optional; absent or null means "none of that kind" (the null-vs-absent rule). An empty payload is a 200 with an empty idMap.
  • Every item needs a non-empty string tempId, unique across the four tree sections (they share one namespace, because idMap is one flat map) - clientCertificates items carry no tempId at all, see below. Temp ids are never stored.
  • A collection's parentTempId and a request's collectionTempId must name a collection tempId in the same payload; references may point forward, so a child may appear before its parent. parentTempId is null (or absent) for a root.
  • Every other field is the one the matching POST /<resource> accepts, with the same defaults and the same null rule - the engine runs the same per-resource field appliers for both paths. id is not accepted here: the engine owns ID generation on this path.
  • order is optional. For collections, an omitted order appends after the existing siblings and then in payload order (each sibling gets the next slot - the per-item default cannot do this in bulk, because none of the payload's own siblings are stored yet). For requests it defaults to 0 on this path only: POST /requests appends by scanning the collection's stored rows, which a bulk write cannot do for a collection it is creating in the same call, so a client that cares about request order must state it here. The app's importer does - and deliberately omits order on its root collections, so an import into a non-empty workspace lands after the roots already there instead of colliding with their 0, 1, 2....
  • A request may carry examples, an array of saved example responses. They are nested rather than a fourth section because nothing references them: they need no tempId, get engine-generated ids, and so do not appear in idMap. Each entry takes the fields POST /requests/:id/examples accepts, through the same field applier, and an entry that fails validation is a per-item 400 naming the request's tempId. origin is among those fields and an importer leaves it at its "import" default, which is what these rows are. An entry with no order takes its payload position, so the stored order is the order the source file listed the responses in.
  • A specs item carries content (required, non-empty) and an optional sourceUrl; its hash and fetchedAt are engine-computed and a per-item 400 if sent, and the size cap is the same live maxSpecDocumentBytes that POST /specs enforces, through the same helper. Spec rows are written before the collections that bind them, in the same transaction.
  • A collection binds a spec through openapi.specTempId (a spec in this payload, resolved through the temp-id map exactly as collectionTempId is) or openapi.specId (one already stored). Sending both is a per-item 400, and so is either one that resolves to nothing. The resolved value is stored as openapi.specId; specTempId is never persisted. The binding's specHash and syncedAt are stamped by the engine from the document - the one this payload just wrote, or the stored one an openapi.specId names - so an imported collection is bound to a version and its runs are measured against the contract (issue #709).
  • A clientCertificates item is a client_certificates registry candidate (issue #1656), in the same field shape POST /client-certificates takes (host, port, certPath, keyPath, certFormat, passphrase) - id is refused here too. Unlike every other section it carries no tempId (nothing else in the payload references a certificate) and is applied after the tree's own transaction commits rather than inside it: the engine reuses POST /client-certificates's own check-and-write, which takes its own lock, and taking it from inside the tree's transaction would deadlock. A candidate that fails that route's own checks (an unreadable file, a (host, port) pair another row already claims) is skipped rather than failing the call or naming an item error - the tree it does not reference has already committed by the time this section runs, and nothing in the response says which candidates landed. Only a Postman preview's own clientCertificates array (POST /import/parse) ever populates this; every other format sends [].
  • Up to 10,000 items per call (collections + requests + environments + specs + nested examples + clientCertificates - they are rows this call allocates and writes, so they count).

Response: 200

{ "idMap": { "c1": "col_<uuid>", "c2": "col_<uuid>", "r1": "req_<uuid>", "e1": "env_<uuid>", "s1": "spec_<uuid>" } }

Every tempId sent appears in idMap. Nested examples do not - they carry no tempId to map.

Atomicity: validation runs over the whole payload before anything is written, and the write itself is a single SQLite transaction. A 400 therefore means nothing was persisted - there is no partial tree to clean up.

Errors: every 400 uses the standard error object, and the per-item ones add an item key inside it (the offending tempId) so a large import can name what broke:

{ "error": { "code": "bad_request", "message": "Duplicate tempId 'c1'", "item": "c1" } }

Messages, by case: - 400 Invalid JSON body - the body did not parse. - 400 Body must be a JSON object. - 400 Invalid 'collections': must be an array - a section was present but not an array (same for requests / environments / specs). - 400 Invalid collection at index 2: 'tempId' must be a non-empty string. - 400 Invalid collection at index 0: 'id' is not accepted - the engine assigns ids; reference items by 'tempId'. - 400 Duplicate tempId 'c1', with item: "c1". - 400 Unknown parentTempId 'c9', with item: "c2", and the same for collectionTempId - including a collectionTempId that names an environment rather than a collection. - 400 Unknown openapi.specTempId 's9', with item: "c1", and 400 Spec 'spec_...' does not exist for an openapi.specId that resolves to no stored document. - 400 Cycle in parentTempId references at 'c1', with item: "c2" - a cycle (including a self-parent) in the payload's own parent graph. The stored-tree walk that guards POST /collections cannot see this one, because none of these rows exist yet, and a cycle makes cascade delete loop forever (issue #79). - 400 Missing required field: name, with item: "c1", and the other per-field errors of the matching POST /<resource>, including a wrong-typed field ("name": 42), which is a 400 rather than a 500. - 400 Import too large: 10001 items exceeds the limit of 10000 per call. - 500 <detail> - the transaction itself failed; nothing was written.

Environments

GET /environments

List all environments.

Response:

[
  {
    "id": "env_1234567890",
    "name": "Production",
    "variables": {
      "baseUrl": {
        "value": "https://api.example.com",
        "enabled": true,
        "secret": false
      }
    },
    "updatedAt": 1234567890
  }
]

POST /environments

Create an environment. Create only - see Resource writes for the shared contract and the null-vs-absent rule.

Request:

{
  "name": "Production",    // Required, no default (null is a 400)
  "description": "",        // Optional
  "isActive": false,        // Optional; true deactivates every other environment
  "variables": {            // Optional, null resets to {}
    "baseUrl": {
      "value": "https://api.example.com",
      "enabled": true,
      "secret": false
    }
  }
}

Response: The created environment object, carrying the engine-generated id.

Errors: 400 if the body carries an id (the engine owns it), or if name is missing or null; 413 naming the field, its size and the cap, when a serialized variables is over the engine's field cap.

PUT /environments/:id

Update an existing environment. Update only - a 404 when the id does not exist, never a silent create. Merge-patch body, same rule as collections.

variables replaces the whole map, so a caller doing a partial edit reads the current map first and sends the merged result (this is what the MCP update_environment tool does). Sending variables: null resets it to {} - it no longer stores the literal string null, which is the bug this verb split fixed. isActive is honored here too; it used to be read only on create.

Writing isActive: true is how a client switches the active environment, and one request does the whole switch: the engine deactivates whichever environment held the flag in the same transaction, so at most one row is ever active and a client sends no companion write to clear the old one. Clearing entirely is isActive: false on the environment that holds it. The engine still never applies the active environment to a request - every execution names its own environmentId - but because the choice is stored rather than kept client-side, it survives a restart and is shared by every client on the same database. See db-schema.md.

Request:

{
  "name": "Production",    // Optional; null is a 400 (no default)
  "variables": {},          // Optional, null resets to {}
  "isActive": true          // Optional, null resets to false; true is the switch
}

Response: The updated environment object.

Errors: 404 if the environment does not exist; 400 on a null name; 413 naming the field, its size and the cap, when a serialized variables is over the engine's field cap.

DELETE /environments/:id

Delete an environment.

Response:

{
  "message": "Environment deleted successfully",
  "id": "env_1234567890"
}

Global Variables

GET /globals

Get global variables (singleton).

Response:

{
  "id": "globals",
  "variables": {
    "apiKey": {
      "value": "xxx",
      "enabled": true,
      "secret": false
    }
  },
  "updatedAt": 1234567890
}

POST /globals

Set global variables.

Request:

{
  "variables": {
    "apiKey": {
      "value": "xxx",
      "enabled": true,
      "secret": false
    }
  }
}

Response: The saved globals object.

Globals is a singleton, so there is no create/update pair: a POST replaces the whole set rather than merging into it. Every write is therefore a create as far as the null-vs-absent rule goes - variables absent and variables: null both mean the default, {}.

variables: null used to store the literal four-character text null, which parses as JSON but is not an object. GET /globals returns {} for anything it cannot read as an object, so the failure showed up as globals silently disappearing rather than as an error. A non-object variables (42, a string, an array) had the same effect and is now a 400 - see Accepted field shapes.

Cookies

The engine keeps a cookie jar for design-mode requests (POST /execute and the pm.sendRequest calls inside it), so a session set by one request is sent on the next. One jar per environment, plus one for requests sent with no environment selected; in memory only, for the life of the engine process, and never written to disk. Load runs neither read nor write it.

Cookies set during a redirect chain follow curl's cross-origin rule rather than the jar's - see POST /execute for what a redirect that changes origin does to them.

GET /cookies

Every jar that holds anything, one entry per scope.

Response:

{
  "scopes": [
    {
      "environmentId": "env_staging",
      "cookies": [
        {
          "name": "session",
          "value": "abc123",
          "domain": ".staging.example.com",
          "path": "/",
          "secure": true,
          "httpOnly": true,
          "expires": 1767225600
        }
      ]
    }
  ]
}

environmentId is null for the no-environment jar - null rather than "" so a client cannot mistake it for an id. expires is unix seconds, or 0 for a session cookie (one that lives until the engine exits). A scope with no cookies left is not reported.

DELETE /cookies

Clear one jar, or all of them.

Query parameters:

Parameter Meaning
(none) Clear every jar
environmentId=<id> Clear that environment's jar
environmentId= Clear the no-environment jar

The three cases are the null-vs-absent rule in a query string: an empty value is a real scope - the one no id can name - and not a mistake.

Response:

{
  "cleared": 3
}

cleared counts the cookies dropped; clearing a scope that holds nothing is a 200 with 0, not an error.

Client certificates

An mTLS endpoint asks the client to present a certificate, and a certificate is a property of where you are calling, not of one request - the transfer that needs it is as often an OAuth token fetch, a redirect or a script's pm.sendRequest as it is the request you are looking at. So the engine keeps a registry of host to certificate and applies the matching entry itself, on every outbound path: design sends, load runs, SSE streams, pm.sendRequest, the OAuth token endpoint, POST /import/fetch and the monitor scrape. Nothing on a request names a certificate.

Rows are read into the transport policy at the point of use, so a change reaches the next transfer without a restart - and, exactly like the proxy, a load run and a collection run read the registry once at run start and hold it, because libcurl reuses a pooled connection only when its TLS identity matches.

Matching is by host, and most specific wins. A host is either an exact name (api.example.com) or the one wildcard form, *.example.com, which is read as a label suffix: it answers for api.example.com and a.b.example.com, never for example.com itself and never for notexample.com. That is the whole syntax - a * anywhere else is a 400 naming the form, rather than a row stored as a hostname no transfer can ever equal - and a wildcard never answers for an address literal (127.0.0.1), which only an exact entry matches.

Three tiers decide which entry answers, closest host first:

Tier Beats Example against api.eu.example.com:8443
1 An exact host beats every wildcard api.eu.example.com wins over *.eu.example.com, even if only the wildcard names the port
2 A longer wildcard beats a shorter one *.eu.example.com wins over *.example.com
3 Within one host, the port beats the catch-all *.example.com + port: 8443 wins over *.example.com + port: null

The order is total, so the answer never depends on the order rows were added: two entries can only rank equally by naming the same host and the same port, and the second such write is a 409. Host comparison is case-insensitive (rows are stored lower-cased), and an IPv6 host is registered without its brackets (::1).

Paths, not contents. The row holds the paths of the certificate and key files and the engine opens them at send time, so the private key never enters the database. The passphrase is the one exception - it is stored, in plaintext, the same as every other saved credential (see db-schema.md) - and it is never sent back: reads carry hasPassphrase instead.

Both file paths are checked when the entry is written, and an unreadable one is a 400 naming the file. That is deliberate: a path that is not there otherwise fails at handshake time as an SSL error against the endpoint, which reads as "the API is broken" rather than "this setting is wrong".

A cert-authenticated exchange says so. POST /execute returns clientCertificate on the response ("" when none matched) and the stored trace carries the same value under the same name, so a restored response names the entry a live one named.

The row says what format its certificate is in, and the engine tells libcurl so. certFormat is pem - a PEM certificate with its key in a second file - or p12, a PKCS#12 bundle carrying both, and it becomes CURLOPT_SSLCERTTYPE on every transfer. Which formats work is a property of the build's TLS backend:

Backend Builds Presents
OpenSSL Linux, macOS, Windows pem and p12
Schannel none since issue #851 p12 only

Mutual TLS works on all three platforms as of issue #851. It did not before: Windows built against Schannel, and curl's Schannel client-certificate path cannot complete the handshake - it imports the bundle with PKCS12_NO_PERSIST_KEY and the key that yields is one Schannel's credential path cannot use, so the transfer failed with SEC_E_INTERNAL_ERROR ... The Local Security Authority cannot be contacted (issue #842). Nothing in Vayu's configuration changed that, so #851 changed the backend instead.

Before moving any leg back to Schannel, read closed issue #842. The defect above is still open upstream: curl 8.21.0 carries two entries for it in its own KNOWN_BUGS (curl issues 17626 and 3145), one of them naming the exact call, and #842 is the measurement record - every driver, with a legacy-PBE and a PBES2 bundle alike. A change that returns to Schannel has to re-instate the wire skip #851 deleted. Schannel also takes no PEM pair at all, so a pem entry would fail at handshake time again, which before issue #833 (when the engine named no type) meant a Windows build could present nothing a user registered.

Both formats are asserted on a wire on every leg (a design send, an SSE stream, a load run and a pm.sendRequest against a listener that demands a client certificate).

A PKCS#12 entry names no key file, because the bundle carries the key: a row that names one is a 400 rather than a stored path nothing would read, and keyPath comes back "". The passphrase field covers both formats - libcurl reads it as the PEM key's passphrase and as the bundle's import password.

The format is checked against the file, and defaulted from it. Leave certFormat out and the engine reads the first bytes of certPath - a PEM marker or the ASN.1 SEQUENCE a DER bundle opens with - and stores what it found, falling back to pem for a file it cannot classify. Name it and it is kept, but a contradiction (a bundle registered as pem, or the reverse) is a 400: the alternative is libcurl's parse error against the endpoint, which is the misdiagnosis this registry exists to end. A file the engine cannot classify is left for the backend to judge rather than refused here.

GET /client-certificates

Every registered entry.

Response:

[
  {
    "id": "cert_7f3c1a2b",
    "host": "api.example.com",
    "port": 8443,
    "certPath": "/home/ada/certs/client.pem",
    "keyPath": "/home/ada/certs/client.key",
    "certFormat": "pem",
    "hasPassphrase": true,
    "createdAt": 1767225600000,
    "updatedAt": 1767225600000
  }
]

port is null - not 0 and not omitted - for an entry that answers on every port. keyPath is "" for a p12 entry, which stores no key path at all.

POST /client-certificates

Register a certificate for a host. Create only - the engine owns the id, so a body carrying one is a 400 (see the engine owns every id).

Body:

Field Required Meaning
host yes Hostname, no scheme, port or path - or *.example.com for every subdomain of it. Stored lower-cased.
port no The port this entry is specific to; null or absent means every port.
certPath yes Path to the certificate file. Must be readable now.
certFormat no pem or p12. Absent or null reads it off certPath, falling back to pem.
keyPath for pem Path to the private key file. Must be readable now, and must be absent for p12.
passphrase no The key's passphrase, or a PKCS#12 bundle's import password. Write-only.

The uniqueness check and the write are one lock scope. Proving no other row already claims this host + port and writing the new row happen under one acquisition of the database lock, so two creates for the same target racing each other cannot both pass the check before either writes - the second is answered with the 409 below rather than landing beside the first.

Errors:

Status When
400 A host that could never match (carries a scheme, a path, a port, or brackets, or a * outside the *.example.com form), a port outside 1..65535, a non-integer port, a missing host / certPath, a pem entry with no keyPath, a p12 entry that names one, a certFormat outside pem / p12, a certFormat the file's own bytes contradict, a file that is not readable, or a body id.
409 Another entry already claims this host + port. Two rows for one target would make the certificate presented depend on row order, so the second is refused with the id of the first rather than silently shadowing it.

PUT /client-certificates/:id

Update an entry. Update only - a missing id is a 404, never a silent create. Merge-patch under the null-vs-absent rule: an absent field keeps its value, port: null widens the entry to every port, passphrase: null clears a stored one, and keyPath: null clears the key path - which is how an entry moves from pem to p12, since a bundle may not name one. certFormat: null re-reads the format off certPath.

Validation runs on the merged row, not on the body, so a PUT that moves only keyPath still proves the pair works together - and a PUT that names certFormat: "p12" without clearing keyPath is a 400, because the merged row would be a bundle naming a key file.

DELETE /client-certificates/:id

Remove an entry; 404 if it does not exist. The certificate and key files themselves are never touched - the registry only ever held the way to find them.

Transport diagnostics

POST /diagnostics/connection

Send one request under the transport policy in force and report which hop answered. Proxy, custom CAs and the client-certificate registry all fail at the first real request otherwise, and libcurl's message there names the endpoint rather than the setting - which is how "my API is down" gets filed against a proxy that was never configured correctly.

{ "url": "https://api.example.com/" }

The probe is a HEAD, with verification on (that is the subject of the test), redirects off (a redirect would move the test to a host the caller never named and report its proxy and certificate as this one's) and a fixed 10-second deadline.

{
  "url": "https://api.example.com/",
  "outcome": "proxy_failed",
  "errorCode": "PROXY_ERROR",
  "detail": "Received HTTP code 407 from proxy after CONNECT",
  "proxy": { "mode": "manual", "url": "http://corp.example:8080" },
  "clientCertificate": ""
}
Field Meaning
outcome ok, proxy_failed, tls_failed, timed_out or failed. Deliberately coarser than ErrorCode: these are the answers that lead a reader to a different setting.
proxy.mode The proxyMode in force. Always present.
proxy.url The proxy that was used, absent when the engine does not know it - every environment-mode test, since libcurl reads those variables itself. Absent means "not the engine's to say", never "no proxy".
clientCertificate The registry entry that answered for this host, "" when none did. Always present, like POST /execute's.
status The status line. Present only on ok.
errorCode / detail The engine's ErrorCode spelling and libcurl's own message. Absent on ok.

A failed connection is a 200. The test succeeded in answering; only a malformed body or a URL that is not http/https is a 400.

It never returns the response body, headers or redirect chain, and a test asserts that. This is a diagnostics surface on the localhost API, not a general fetch proxy - that is POST /import/fetch, behind its own byte bound.

Webhook Inbox

An inbox is a second HTTP listener the engine opens on request. It accepts any method on any path, records what arrived, and answers a canned response - nothing else. That is what makes testing the receiving side of a webhook a local operation: point the sender at the inbox URL instead of a cloud tunnel, and the payload never leaves the machine.

Lifetime is the engine process. An inbox is not a stored resource: there is no create/update split (POST creates, PUT updates applies to collections, requests and environments), ids are not restorable across restarts, and POST /inbox/start is a verb path for that reason. Stopping an inbox frees its listener but keeps the record - and therefore its captures - readable until the engine exits; DELETE /inbox/:inboxId is what removes both.

Binding is a trust decision. The default is 127.0.0.1. Any other address is refused unless the caller also sends "confirmNonLoopback": true, and the inbox reports loopback: false from then on so a client can badge it. See architecture.md for why only the inbox listener may bind wide and the management API never may.

Bounds. Three are settings (GET/POST /config, category Services), read once when an inbox starts - so a change applies to the next inbox started, and a running listener keeps what it was started with:

Setting Default Range What it bounds
inboxMaxBodyBytes 65536 256 - 8388608 Stored body per capture. Past it the body is kept as a prefix with bodyTruncated: true; bodyBytes is always the size as received
inboxMaxCaptures 500 1 - 10000 Captures retained per inbox, oldest evicted first. Also the ceiling on one limit of the capture list
inboxLivePollIntervalMs 250 25 - 5000 How often a watched inbox checks for new captures - the delay between a webhook landing and its event

Three are not settings, deliberately. A request over 8 MiB is refused at the transport with a 413 and recorded nowhere: that bounds what an unauthenticated remote caller can make the engine buffer, which is not the local user's preference to spend. The canned response's delayMs is capped at 30000: it holds a listener thread for its whole duration and a stop waits on that join, so it bounds how long a stop can be made to take. The request target - path and query together - is capped at 8192 bytes by the transport, past which it answers 414.

Below that cap the path's length does not matter, and "any path" is meant literally: an inbox routes every request to its capture without matching the path against anything, so a signed callback URL, a per-delivery token in the path or a deeply nested tenant route is recorded like any other (issue #1140).

CORS is on by default, with no setting to turn it off - the same reflected Access-Control-Allow-Origin / -Allow-Credentials / Vary pair a mock server sends (see Mock Server), so a browser-hosted sender can read the canned response. A real preflight (a request carrying Access-Control-Request-Method) is answered 204 and never reaches the capture, the canned delay or the canned status - a preflight must get a 2xx or the browser aborts before the real request arrives, so a canned response configured with a non-2xx status (to exercise a sender's retry path) would otherwise break every browser-hosted sender silently. A header the canned response sets that names Access-Control-* or Vary is dropped rather than echoed, since the engine already answered those; a credentialed request (Origin present and not the literal null) rewrites a wildcard Access-Control-Expose-Headers into the canned response's own header names, per the Fetch spec's rule that * there is a literal name, not a wildcard, under credentials.

POST /inbox/start

Start a listener. Every field is optional - an empty body starts a loopback inbox on a free port that answers 200 with no body.

Request:

{
  "port": 0,
  "bind": "127.0.0.1",
  "confirmNonLoopback": false,
  "response": {
    "status": 200,
    "body": "",
    "headers": {},
    "delayMs": 0
  }
}

Field Meaning
port 0 (default) picks a free port
bind Default 127.0.0.1; anything outside 127.0.0.0/8 and ::1 needs confirmNonLoopback. Loopback is decided by parsing the address, so a hostname that merely starts 127. (or localhost.example.com) is not loopback and needs the confirmation
response.status 100-599
response.headers String values only; a Content-Type here is used verbatim
response.delayMs 0-30000, applied before every reply

An out-of-range value is a 400 naming the field rather than a fallback to the default: a listener quietly answering something other than what it was asked to is invisible on both sides of the wire. A bind that fails is a 409 with code inbox_bind_failed; a non-loopback bind without confirmation is a 400 with code inbox_non_loopback_bind.

A port another engine listener is already running on - an inbox or a mock issuer - is refused with that same 409, naming the holder ("inbox inbox_2f1c is already listening there"). The engine checks that itself rather than letting the bind report it: listeners are bound with SO_REUSEPORT, so on Linux a second bind on the same address and port succeeds and the kernel then splits arriving connections between the two listeners, each capturing an effectively random half. A port held by a process outside the engine is still reported by the bind, with the "in use or unavailable" wording; port: 0 is never refused this way, since the kernel does not hand out a port it is already using.

Response:

{
  "inboxId": "inbox_2f1c...",
  "url": "http://127.0.0.1:41235/",
  "bind": "127.0.0.1",
  "port": 41235,
  "running": true,
  "loopback": true,
  "captureCount": 0,
  "response": { "status": 200, "body": "", "headers": {}, "delayMs": 0 }
}

captureCount is how many captures the inbox is holding right now - what a DELETE /inbox/:inboxId would destroy with it. Every route that returns an inbox fills it, so a client can word a confirmation without a second round trip.

GET /inbox

Every inbox this process has started, running or stopped: {"data": [ ... ]}, each entry the object above.

PUT /inbox/:inboxId

Update the canned response, live - the next caller receives the new one, with no restart and no captures lost. Merge-patch: an absent field keeps what the inbox is serving. The body may be the response object itself or {"response": {...}}, so a client can send back what start handed it. 404 for an unknown id.

Atomic against a concurrent update, the same guarantee the resource PUTs above give (issue #1454): the read, the merge and the write are one acquisition of the inbox's own lock, not the database's - the canned response lives in memory - so two clients patching one inbox at the same moment each merge onto what the other just committed rather than onto the response as both of them found it.

POST /inbox/:inboxId/stop

Stop the listener. Returns the inbox with running: false. Captures survive; 404 for an unknown id. A stop is not a delete - DELETE /inbox/:inboxId is.

DELETE /inbox/:inboxId

Stop the listener, drop the record, and delete its captures with it: {"inboxId": "...", "capturesDeleted": 12}. 404 for an unknown id.

A running inbox is stopped rather than refused - one call, because the caller's intent is that the inbox be gone. That is safe rather than racy because the teardown joins every in-flight handler before returning, so nothing can still be capturing when the rows are cleared. The order is stop, then clear, then drop the record: a database failure therefore leaves the inbox in place - stopped, and deletable again - rather than orphaning captures no inbox could list.

The captures dying with the record is the point, and is why stop keeps them: they go by explicit user intent here, instead of the record living to the end of the process to protect them. There is deliberately no restart route: delete and start a new inbox. A restart would have to decide what happens to the existing captures, and this way the user decides.

GET /inbox/:inboxId/requests

The captures, newest first, in the standard {data, pagination} envelope.

Query parameters: limit (default 50, capped at 500), offset (default 0).

{
  "data": [
    {
      "id": 12,
      "inboxId": "inbox_2f1c...",
      "receivedAt": 1767225600000,
      "method": "POST",
      "path": "/hooks/order",
      "query": "attempt=2",
      "headers": { "Content-Type": "application/json" },
      "body": "{\"id\":7}",
      "bodyBytes": 8,
      "bodyTruncated": false,
      "remoteAddr": "127.0.0.1"
    }
  ],
  "pagination": { "total": 1, "limit": 50, "offset": 0, "hasMore": false, "returned": 1 }
}

id is the capture's storage id and also its SSE event id (see below). headers joins a repeated name with ,. query is the raw query string without the ?.

DELETE /inbox/:inboxId/requests

Clear the captures, keeping the listener: {"inboxId": "...", "cleared": 12}.

GET /inbox/:inboxId/live

Server-Sent Events, one event per capture, each carrying the same object as the list above and an SSE id: equal to the capture's id. A reconnect that sends Last-Event-ID resumes after that capture, so nothing is missed across a drop. The stream ends when the inbox is stopped.

?lastEventId=<id> is the same resume point as a query parameter, for a client that reconnects by hand: browser EventSource sets Last-Event-ID only on its own retry and exposes no way to set a header on a fresh connection. The header wins when both are given, being the more recent of the two. A value that is not a non-negative capture id is refused with 400 and code invalid_last_event_id rather than resumed from the start, which would replay every retained capture as though it had just arrived.

One stream per inbox. A second concurrent watcher is refused with 409 and code inbox_live_in_use: each SSE handler occupies a cpp-httplib pool thread for its whole life, so N watchers on one inbox is N parked threads.

A claim whose holder stopped writing is taken over, not refused. A stream learns its socket died only when its next write fails, up to one inboxLivePollIntervalMs later, so a client reconnecting inside that window used to meet a 409 it could not recover from. A holder that has not written successfully for two poll intervals (at least 100ms) is not writing, and its slot goes to the newcomer; the evicted stream ends on its next write. Every live stream writes at least a keep-alive each interval, so a genuinely live watcher is never evicted and a second concurrent one is still refused.

Mock Server

A mock server is a listener that answers a collection's saved examples on the paths its requests describe. It is what examples are for: import a spec, and the responses it documented become a running upstream you can build a frontend against, or point a Vayu load run at, without a cloud plan or a second machine.

A mock serves the stored body verbatim, including a partial one. An example whose bodyTruncated is true holds only the first slice of the response it was captured from, and the mock answers with those bytes and the recorded headers as though they were a whole response - nothing on the wire says otherwise, and the flag deliberately does not change what is served. It is disclosure, not behaviour: the app paints a "Partial body" chip on the row so the choice to serve it is a made one (issue #659).

Lifetime is the engine process, exactly as for an inbox and an issuer - a verb path starts it, ids are not restorable across restarts, and stopping one drops its record. There is no stopped state to read: a mock holds nothing that outlives its listener, unlike an inbox and its captures.

Load-testing against a mock is a supported workflow, and it is the one this listener exists for as much as frontend development: a mock is a known-latency, zero-cost upstream on the same machine, so a run against it measures the generator rather than someone else's service, and costs nobody a bill. Start the mock, then point a run at one of its paths:

curl -s localhost:9876/mock/start -d '{"collectionId":"col_abc123","latencyMs":5}'
# -> {"mockId":"mock_5f2a","url":"http://127.0.0.1:43117", ...}

curl -s localhost:9876/runs -d '{
  "mode":"constant_rps","targetRps":500,"duration":"30s",
  "url":"http://127.0.0.1:43117/pets","method":"GET"
}'

latencyMs is what makes the baseline realistic rather than degenerate, and errorRatePct is how a run's error handling and threshold verdict get exercised without breaking a real service.

Loopback only. There is no bind field. Unlike an inbox - which serves capture-and-echo and nothing else, so a LAN-visible one is defensible - a mock re-serves stored response bodies verbatim, and a recorded response can carry whatever the real one did. See architecture.md.

The route table is a start-time snapshot. It is built once, from the collection and every collection under it (an OpenAPI import files its requests in a folder per tag, so the subtree is the only useful unit), and a running mock does not reload edits: stop it and start it again. An example saved from the app's response viewer (issue #588) is an edit like any other - it is appended after the request's existing examples, so a restart keeps answering with the same first one, and the new row is only reachable once the mock is restarted.

How a request is matched.

  • The stored URL is reduced to a path: scheme and host - or the {{baseUrl}} variable standing in for them - the query and the fragment are all dropped.
  • All three template spellings are one wildcard segment: {{petId}} (Vayu and Postman), {petId} (OpenAPI) and :petId (Postman's other form). A wildcard matches exactly one non-empty segment. The normalization is pinned to the importers' own by engine/tests/fixtures/path-template-conformance.json, which both suites read - the app writes these URLs and the mock reads them back.
  • Specificity wins, not registration order: /pets/mine answers GET /pets/mine even when /pets/{{petId}} was stored first.
  • A trailing slash and a repeated / are the same route.

What a miss says. The message is most of the debugging value, so the three outcomes are distinct rather than one blanket 404:

Case Status code Message names
No request has that path 404 mock_no_route The method and path, and how many routes are served
The path matches, the method does not 404 mock_method_mismatch The matching path template and the methods it is served for
A route matched but its request has no saved example 501 mock_no_example The request's name, and that an example must be saved or imported

CORS is on by default, with no setting to turn it off. A mock server answers whatever calls it - most often a browser page on another origin, not only curl - so every response carries Access-Control-Allow-Origin, echoing the request's Origin header (with Access-Control-Allow-Credentials: true and Vary: Origin) when present and not the literal null a sandboxed frame or file:// page sends, or * otherwise, plus Access-Control-Expose-Headers: * so a page's fetch can read a header the example set - rewritten to the example's own header names on a credentialed response, since the Fetch spec reads * there as a literal name, not a wildcard, once credentials are in play. A header the example itself carries under Access-Control-* or Vary is dropped rather than echoed, since the engine already answered those. This applies to a miss too - a 404 with no CORS headers reads in a browser as an opaque "CORS error" instead of the mock_no_route / mock_method_mismatch / mock_no_example body above.

A real preflight - a request carrying Access-Control-Request-Method - is always answered synthetically (204, Access-Control-Allow-Methods / -Allow-Headers echoing what the browser asked for, Access-Control-Max-Age: 600), whether or not a route is stored for that path under OPTIONS: breaking the preflight to surface a stored route's 404/501 would break the browser request behind it for nothing. It does not count toward a route's hits or appear in GET /mock/:mockId/activity. A plain OPTIONS request carrying neither header is ordinary traffic and is served or missed like any other verb.

Bounds (rails, not settings): at most 8 mock servers at once (a listener thread each), at most 2000 routes in one table, and latencyMs capped at 30000 - it holds a listener thread for its whole duration and a stop waits on that join.

POST /mock/start

Request:

{
  "collectionId": "col_abc123",
  "port": 0,
  "latencyMs": 0,
  "errorRatePct": 0
}

collectionId is required. port 0 (the default) binds a free one. latencyMs (0 - 30000) delays every answer; errorRatePct (0 - 100) replaces that share of answers with a synthesized 500 carrying mock_injected_error. Every out-of-range value is a 400 rather than a clamp.

Response:

{
  "mockId": "mock_5f2a",
  "collectionId": "col_abc123",
  "collectionName": "Pet Store",
  "url": "http://127.0.0.1:43117",
  "port": 43117,
  "latencyMs": 0,
  "errorRatePct": 0,
  "routeCount": 12,
  "routesWithoutExample": 3,
  "createdAt": 1735689600000
}

url has no trailing slash - it is a base to concatenate a path onto. routesWithoutExample is the number that explains an otherwise empty-looking mock, which is why it is reported rather than left to be discovered one 501 at a time.

Errors: 404 for a collection that does not exist; 400 mock_no_requests when the collection and its subtree hold none (a listener that 404s everything is never what the caller meant); 400 mock_too_many_routes past the table bound; 409 mock_limit_reached at the server budget; 409 mock_bind_failed when the requested port is taken, naming the holder when this engine is the one holding it.

GET /mock

{"data": [...]} - every running mock, in the shape above.

GET /mock/:mockId/routes

The table the mock is serving. This is how "the mock 404s that path" gets diagnosed without sending a request per guess.

{
  "data": [
    {
      "requestId": "req_1",
      "requestName": "Get pet",
      "method": "GET",
      "path": "/pets/{{petId}}",
      "hasExample": true,
      "mode": "first",
      "hits": 3,
      "status": 200,
      "exampleName": "200 OK"
    }
  ]
}

status is 0 when hasExample is false - there is no example whose status it could be. mode is the request's mock_response_mode ("first" / "fixed" / "random"); hits is how many times this route has answered a request, since the mock started. exampleName is the example that would answer for "first" or "fixed" - it is absent for "random", since which one answers varies per request. 404 for an unknown mock.

GET /mock/:mockId/activity

What this mock has served, newest first: {"data": [...]}, at most limit entries (query param, default 50, capped at 200; a non-numeric value is a 400). Each entry is one inbound request, whichever of the three ways it was handled:

{
  "data": [
    {
      "at": 1735689600123,
      "method": "GET",
      "path": "/pets/1",
      "requestId": "req_1",
      "requestName": "Get pet",
      "exampleId": "ex_1",
      "exampleName": "200 OK",
      "status": 200,
      "injectedError": false
    }
  ]
}

requestId / requestName are null when nothing in the route table matched the path at all, and also for an injected failure (injectedError: true): the error-rate roll happens before route resolution, so an injected 500 never reaches route matching, even when a route would have matched. exampleId / exampleName are null alongside them, and also when the matched route had no saved example (status: 501). injectedError: true marks a synthesized errorRatePct failure. 404 for an unknown mock. Like the route table, this log is discarded when the mock stops - there is nothing to read after that.

POST /mock/:mockId/stop

Stops the listener and drops the record: {"mockId": "...", "stopped": true}, or 404. In-flight answers - including one inside its configured latencyMs - are joined before this returns.

Authentication

The engine resolves auth server-side. Every request's auth object (on POST /execute and POST /runs) is applied to the outgoing request before it hits the wire:

auth.mode Effect
none / inherit No-op (inherit is resolved by POST /compose before it reaches an execution endpoint; one arriving unresolved is a warning in the logs)
bearer Authorization: Bearer <token>
basic Authorization: Basic <base64(user:pass)>
apikey Header key: value, or ?key=value when in: "query"
oauth2 Acquires/caches a token (below) and injects it per tokenPlacement

A user-supplied Authorization header always wins over bearer/basic/oauth2. Header names are matched case-insensitively.

OAuth 2.0 token cache

Tokens are acquired once and cached (SQLite oauth_tokens, keyed by a deterministic cacheKey = accessTokenUrl \x1f clientId \x1f credentialsId \x1f username-if-password-grant). Expiry uses a 45s skew; a missing expires_in means non-expiring. A load run also refreshes its token during the run: a watchdog re-acquires a header-placed, expiring token oauth2RefreshLeadMs (default 60s) before it expires and writes the new one into this same cache row (see Load Test Mode). It is skipped - the token is fetched once and reused for the whole run - for a query-placed token, autoRefreshToken: false, a non-expiring token, an authorization_code grant with no refresh token, a token that lost to a user-supplied Authorization header, and scenario runs, whose steps each resolve auth at plan time. That list is plan_auth_refresh's (engine/src/http/auth_resolver.cpp); the app's isMidRunRefreshable mirrors all of it but the header case - change one, change both.

POST /oauth2/token

Acquire (or return a cached) token for an OAuth2Config. Supports the client_credentials, password, and authorization_code grants; the authorization_code grant requires an interactive code exchange (see below).

Request:

{
  "config": { "grantType": "client_credentials", "accessTokenUrl": "https://idp/token",
              "clientId": "...", "clientSecret": "...", "scope": "openid" },
  "force": false,
  "interactive": { "code": "...", "codeVerifier": "...", "redirectUri": "..." }
}

force: true bypasses the cache (refreshes via the refresh token when present, else re-acquires). interactive is only used for the authorization_code grant.

Response (200):

{
  "cacheKey": "https://idp/token...",
  "accessToken": "ya29...",
  "tokenType": "Bearer",
  "scope": "openid",
  "expiresIn": 3600,
  "createdAt": 1234567890000,
  "expiresAt": 1234567893600,
  "hasRefreshToken": true
}

expiresAt is null for a non-expiring token; scope is omitted when empty. Errors carry an oauth2_* code: 400 invalid config, 401 provider rejected the request, 409 interactive authorization required, 502 network error.

GET /oauth2/token?key=<cacheKey>

Inspect the cached token for a key (used by the UI status row). Always 200.

{ "found": true, "expired": false, "token": { "...": "serialized token" } }

Returns { "found": false } when no token is cached.

DELETE /oauth2/token?key=<cacheKey>

Clear a cached token. 200 { "deleted": true } (false if nothing was cached).

Interactive Authorization Code flow

For the authorization_code grant the engine owns PKCE (S256), the state value, and the code exchange; the app only opens the browser. In loopback mode the engine binds an ephemeral 127.0.0.1 listener; in embedded mode (providers that reject loopback redirects) the app captures the redirect URL and hands it back.

Method / Path Purpose
POST /oauth2/authorize/start {config, mode?} → {attemptId, authorizeUrl, redirectUri}
GET /oauth2/authorize/:attemptId Poll status → {state: "pending"\|"completed"\|"failed"\|"not_found", error?, cacheKey?}
POST /oauth2/authorize/complete {attemptId, callbackUrl} → status (embedded mode)
DELETE /oauth2/authorize/:attemptId Cancel → {cancelled: true}

Attempts time out after 5 minutes; on success the token is written to the cache and cacheKey is returned.

Local mock issuer

A built-in OAuth 2.0 issuer for developing and testing auth flows offline - no real identity provider, so no 2FA prompts, provider rate limits or "suspicious login" mail in the dev loop. Each issuer is an independent 127.0.0.1 listener serving /token and /authorize; point any OAuth 2.0 config's accessTokenUrl at the tokenUrl it returns.

It is not an IdP. JWKS/RS256, OIDC discovery documents, token introspection, consent screens and multi-tenant realms are deliberate non-goals.

Method / Path Purpose
POST /mock-issuer/start Start one → {issuerId, issuerUrl, tokenUrl, authorizeUrl, signingKey}
GET /mock-issuer List the running issuers
PUT /mock-issuer/:id Update failureMode / slowMs / expiresInSeconds live
POST /mock-issuer/:id/stop Stop one → {stopped: true} (404 if unknown)

Start body (every field optional):

{
  "port": 0,
  "expiresInSeconds": 3600,
  "claims": { "sub": "alice", "roles": ["admin"] },
  "clients": [{ "clientId": "cid", "clientSecret": "s3cret" }],
  "failureMode": "none",
  "slowMs": 2000,
  "issueRefreshTokens": true
}

port: 0 (the default) binds an ephemeral port; an explicit port another engine listener already holds is a 500 with code mock_issuer_bind_failed naming that listener, for the same SO_REUSEPORT reason the inbox refuses one. clients empty accepts any client id; with clients configured, an id must be one of them and one carrying a secret must present it (Basic header or body - both RFC 6749 §2.3.1 placements). A field present with the wrong type or an out-of-range value is a 400 (mock_issuer_invalid_config) rather than a silent fallback to the default - a mock issuer running with an expiry other than the one asked for would defeat the purpose. At most 8 issuers run at once (429 mock_issuer_limit_reached).

The issuer's own endpoints:

  • POST /token - client_credentials, password, authorization_code and refresh_token grants, application/x-www-form-urlencoded as RFC 6749 §3.2 requires. Answers {access_token, token_type: "Bearer", expires_in, refresh_token?, scope?}. Authorization codes are single-use and expire after 5 minutes; a refresh grant rotates its token (the presented one is spent).
  • GET /authorize - auto-approves and 302s straight back to redirect_uri with code and state, so the interactive flow completes with zero human steps. PKCE is verified when a code_challenge is present; code_challenge_method must then be S256 (plain is refused rather than quietly accepted). An unknown client or a missing redirect_uri answers in place rather than redirecting (RFC 6749 §4.1.2.1).

The access token is an HS256 JWT signed with the per-issuer signingKey the start call returned - hand that key to the service under test as its shared secret and it can verify the mock's tokens. The payload is the configured claims plus iss, iat, exp and jti (these four always win, since they describe the token being issued), with sub, client_id and scope filled in only when the claims did not set them.

failureMode is what makes retry and error handling testable, and can be flipped on a running issuer with the PUT:

Mode /token answers
none Normally
slow Normally, after slowMs
server_error 500 {"error": "temporarily_unavailable"}
invalid_client 401 {"error": "invalid_client"}

Issuers bind 127.0.0.1 only - never configurable, because they mint bearer tokens and the engine has no route auth. State is in-memory: a restart forgets every issuer, and stopping one drops its codes and refresh tokens with it.

Execution

POST /compose

Compose a request without sending it: resolve {{variables}} and inherit auth engine-side and return the execute-ready payload that POST /execute and POST /runs accept unchanged (issue #226). Pure - no traffic, no run row - which is what lets a client (e.g. MCP's allowlist gate) inspect the resolved request before anything is sent. Composition is still the only place a payload is composed; a payload that skips it is sent byte-for-byte as supplied. Since issue #1008 the execution endpoints are not silent past that point either: a name composition could not answer keeps its braces (issue #1009) instead of resolving to "", and both POST /execute and a scenario step resolve it once more, after the pre-request script and before the send, against whatever the script just wrote. A value composition already substituted is finished text and is never re-resolved - see POST /execute and Scenario runs for what that changes.

Request - at least one of requestId / request is required:

{
  "requestId": "req_1234567890",   // Optional: compose the saved request
  "request": {                      // Optional: an inline unresolved request
    "method": "POST",
    "url": "https://{{host}}/users",
    "headers": { "X-Token": "{{token}}" },
    "body": { "mode": "json", "content": "{\"name\":\"{{name}}\"}" },
    "auth": { "mode": "inherit" },
    "elements": []
  },
  "collectionId": "col_1234567890", // Optional: chain scope for an inline request
  "environmentId": "env_1234567890", // Optional: environment scope
  "dataColumns": ["username", "city"], // Optional: bare names a bound row will substitute
  "deferDynamicVariables": true      // Optional: this payload is for a run that repeats it
}
  • dataColumns (issue #1007) names the columns of the data file a caller will bind a row from after this compose call - an array of column names, never values, since a plan is composed once and a row is bound per iteration. Every name it lists is left written as it stands, exactly as the reserved {{data.*}} namespace already is, for a later per-row bind to join - {{username}} behaves as {{data.username}} always has, deferred rather than resolved from a same-named variable. Absent or null means no dataset: composition resolves exactly as it did before this field existed. Refused with 400 invalid_compose_request when the field is present and not an array, or holds an entry that is not a string or is the empty string. A data.-prefixed entry is accepted but redundant - the reserved namespace answers that spelling first regardless of whether it is also listed here, so either way it is deferred. See {{data.*}} puts the row into the request itself and D18 for the precedence this buys.

  • deferDynamicVariables (issue #995) says this payload is being composed for a run, which will send it many times. A generator resolved here would be one value repeated by every iteration of every virtual user - the uniqueness {{$randomUUID}} is written for, lost exactly where concurrency makes collisions matter - so with the field true every name the dynamic table generates is left written as it stands, the way {{data.*}} and the identity names already are, and the run's executor generates a fresh value per occurrence immediately before each send. Absent, null or false composes exactly as it did before this field existed, which is what a Send, a preview and a script's replaceIn want: composed once, sent once. Refused with 400 invalid_compose_request when present and not a boolean. Two limits worth knowing: an unknown {{$typo}} is unaffected (nothing would generate it either way, so it keeps its braces - issue #186), and a generator inside the auth block is generated here even when the field is true, because apply_auth encodes a credential when the request is built and a token left for the bind would go out as base64 of its own text. That is a generator's case alone: a credential carrying a {{data.*}} or a {{$vu}} defers the whole build and is bound before the encoding (issue #1055), where a generator has nothing to wait for. A scenario plan's steps are composed with it internally, so a collection run needs no field at all.

  • requestId composes the stored request wholesale: URL, flattened enabled headers (later duplicates win), body, auth (absent auth defaults to inherit), the resolved elements list (collection chain root→leaf, then the request's own, each stamped with its origin, disabled and blank script.* entries dropped the same way - see Elements), the stored execution options (followRedirects / maxRedirects / httpVersion / verifySSL, always emitted) and its requestName (the script sandbox reads it as pm.info.requestName; omitted when the row's name is empty). The request's own collection scopes resolution; collectionId is only a fallback for a request without one. An unknown id is a definitive 404.

  • request is an inline unresolved request in the POST /execute body shape. Given alongside requestId, its fields lay over the stored ones before resolution - how an override like "retarget this saved request at another URL" works. Given alone, collectionId scopes the variable chain and the inherit walk. Unknown scope ids degrade to an empty scope rather than erroring - composition works with no collection or environment at all.

What gets resolved: the URL, header keys and values, body content and fields, and every string inside the winning auth block - after inherit is walked (leaf→root; an explicit noauth terminates the walk, none is stepped over) and strictly before any OAuth 2.0 cache key is derived from the config. Script text is never interpolated - a {{...}} in a script is user JavaScript. Resolution semantics (precedence, unknown names, dynamic variables, the D17 malformed-data rules) are specified in variable-resolution and pinned by the shared conformance fixture (engine/tests/fixtures/variable-resolution-conformance.json).

Response: 200 with the composed payload - the POST /execute body shape, with requestId / environmentId echoed so the result can be POSTed onward unchanged. An auth that resolves to "send nothing" is an absent auth field.

Errors use the engine's single nested shape (see the top of this page), with specific codes:

  • 404 {"error": {"code": "request_not_found", "message": "..."}} - unknown requestId.
  • 400 {"error": {"code": "invalid_compose_request", "message": "..."}} - malformed JSON, neither requestId nor request, or a field of the wrong type.
  • 400 {"error": {"code": "unsendable_header", "message": "..."}} - a variable substituted into a header name or value carries a byte no header line can hold. This is the one payload composition rejects over a value rather than over its shape, and it is deliberate: a header line ends at CRLF and has no escape for it, so ok\r\nX-Admin: true arriving from an environment variable does not sit in the header - it ends the line and makes the remainder a header nobody wrote. The message names the variable, because composition is the last layer that knows which one carried the byte. A NUL is refused with it: the engine spells a header key + ": " + value for curl_slist_append, which reads to the first NUL, so the rest of the line would go out missing rather than refused. The same bytes remain ordinary text in a URL, a body and a form field's value, which compose unchanged.

A header carrying either byte from any other origin - a script's assignment to pm.request.headers, an auth credential, an import, a payload posted straight to POST /execute - is refused one step later, before any transfer starts: the response carries statusCode: 0 and an errorCode of INTERNAL_ERROR whose message names the header instead of a variable. The same refusal covers a multipart part's field name, declared filename and per-part content type, which libcurl writes into that part's own header block. A bound {{data.column}} is refused earlier still, at bind time, naming the column and the row (see Scenario runs). - 400 {"error": {"code": "colliding_header_names", "message": "..."}} - two header names resolved to one, so one of the two headers would be gone. A header map holds one value per name, so {{tenant_header}}: acme beside a literal X-Tenant: legacy is not two headers once the variable answers X-Tenant - it is one, and which one arrives is an ordering detail rather than a rule. Names are compared without case, as Headers compares them, so a {{h}} resolving to authorization refuses beside an Authorization the caller typed. The message names both spellings as written and the name they produced; nothing is repaired, for the reason a forged header is refused rather than stripped.

Only a collision resolution produced is refused. Two names the caller typed are two entries they can see, and the stored flattening's later-wins rule (above) still decides those - the same distinction the bind-time refusal draws (see Scenario runs). The execute-time residual pass refuses the same collision in the same words, since it rebuilds the same map: a buffered send answers statusCode: 0 with an errorCode of INTERNAL_ERROR carrying the message, exactly as the pre-send gate does, while a streaming send - which has not answered yet - is a 400 with this same code and fails its run row. A script's own pm.sendRequest refuses it as well, with the call named in front of the same wording, because it too rebuilds a header map and a resolved name is a different key (issue #1067). - 400 {"error": {"code": "empty_header_name", "message": "..."}} - a header name resolved to the empty string, so the line would go out under no name at all. {{blank}}: acme with blank holding "" is not a header the caller can see: the name is a map key, the key is empty, and what libcurl is handed is the line ": acme". Nothing further down refuses it - the pre-send gate reads a header's text for the bytes that end a line early, and an absent name ends nothing - so composition is where it is caught. The message names the name as written, which is all there is to name; what it produced is nothing.

Refused however the name got there, unlike the collision above. A collision has to be one resolution produced, because two names the caller typed are two entries they can see; a name that is not there is nothing to see whoever wrote it. Both flattenings that feed composition drop such a row before it arrives - a stored request's headers and the app's - so what this catches beyond a produced name is a payload built by hand. The execute-time residual pass refuses it in the same words and under this same code, in each of the two shapes it answers a refusal in (above), and for this rule it reads every header name rather than only the ones still holding a token (issue #1095) - so a name a pre-request script has just emptied and an empty key posted straight to POST /execute are refused there as well. The collision rule keeps the narrower reach and loses nothing by it: a request that arrives already resolved cannot carry a collision, two names that are already equal being already one key. pm.sendRequest has refused it since its own header names began resolving, with the call named in front of the same wording, and a data row that binds a name away is refused at bind time with the row in front of it (see Scenario runs).

POST /execute

Execute a single HTTP request (Design Mode). Returns immediate response with test results.

Alias: POST /request (deprecated - see Deprecated aliases).

The request's auth (see Authentication) is resolved before the pre-request script runs, so pm.request reflects the real outgoing headers. If a non-interactive OAuth 2.0 token cannot be obtained, the engine still returns 200 but the body carries statusCode: 0, an errorCode of AUTH_REQUIRED (interactive sign-in needed) or AUTH_FAILED, and an authErrorCode hint.

A name compose could not answer is resolved once more, after the pre-request script and before the send (issue #1008). Composition still runs first and is still the only place a payload is composed - what changed is that an unknown ordinary {{name}} keeps its braces at compose time instead of becoming "" (issue #1009), so it survives to be resolved here against the variable scopes as the pre-request script left them. This is what makes the canonical imported auth pattern work: a pre-request script does pm.environment.set("token", …) and the same send's Authorization: Bearer {{token}} carries the fresh value, rather than the previous run's token or "" on the first run. It resolves the same fields composition does - the URL, header names and values, body content, and the five strings a form field carries - by the same resolver and the same rules (scope precedence, nested resolution, cycles, the 8-level bound); a value composition already substituted is finished text and is not touched again. {{data.column}} is left alone here too - the data namespace is bound per iteration by whoever owns the row. A request skipped by pm.execution.skipRequest() resolves nothing, having nothing left to send. POST /compose's own output is unchanged - a preview, a tab title, the unresolved-token painting and "Copy as cURL" still show compose-time resolution, so a preview can show {{token}} where the wire will actually carry the resolved value. The LOAD path does not do this pass - a load run never runs a pre-request script, so there is nothing for it to resolve against; see Scenario load runs.

Request:

{
  "method": "POST",
  "url": "https://api.example.com/users",
  "headers": {
    "Content-Type": "application/json"
  },
  "body": {
    "mode": "json",
    "content": "{\"name\":\"John\"}"
  },
  "requestId": "req_1234567890",      // Optional, links to saved request
  "requestName": "Create user",       // Optional, read by scripts as pm.info.requestName
  "environmentId": "env_1234567890",  // Optional, uses environment variables
  "elements": [                        // Optional, typed behaviours - see Elements. The only
                                       // script source; preRequestScript/postRequestScript(s)/tests
                                       // are refused (400, naming "elements")
    { "kind": "script.post", "config": { "script":
      "pm.test('Status is 200', () => pm.expect(pm.response.code).to.equal(200));" } }
  ],
  "allowScriptRequests": false,        // Optional, default false - see below
  "followRedirects": true,             // Optional, default true
  "maxRedirects": 10,                  // Optional, default 10
  "verifySSL": true,                   // Optional, default true - stored per request and
                                       // settable in the request builder's Settings tab
  "httpVersion": "auto",               // Optional: "auto" | "http1.1" | "http2", default "auto"
  "transient": false,                  // Optional, default false - see below
  "stream": false,                     // Optional, default false - see below
  "maxStreamDurationMs": 600000,       // Optional, streaming only - see below
  "maxStreamEvents": 100000,           // Optional, streaming only - see below
  "data": { "id": "7" },               // Optional, one data row - see below
  "disabledDefaultHeaders": []         // Optional, headers Vayu adds that this send refuses
}

disabledDefaultHeaders refuses what the engine would add (issue #1229). The engine adds a small declared set to every request that does not name it - see Default request headers - and a testing tool has to be able to send exactly the request that was written, including one with no User-Agent at all. Names are matched case-insensitively; the field is accepted on POST /execute and POST /runs alike. A value that is not an array of header names is a 400 naming the field, because a malformed opt-out would otherwise read as one that did nothing. A name matching no current default is accepted and does nothing: config can switch a default off between the moment a client read the declared set and the moment it sends.

stream consumes a text/event-stream response live (issue #573) instead of buffering it. It changes the execution model, so it is declared rather than detected: the engine creates the run row, hands the transfer to a managed consumer worker, and answers 202 at once with the run and the URL its events arrive on. Nothing about a non-streaming send changes.

{
  "runId": "run_1234567890",
  "eventsUrl": "/runs/run_1234567890/events",
  "status": "running"
}

The worker parses SSE frames into a bounded in-memory ring that GET /runs/:runId/events relays, and writes a bounded events node into the run's stored trace when the stream ends, so History restores the timeline.

Every stream ends, and the run says why. Five terminations, never "the timeout happened to fire":

Reason What happened
completed The server closed the stream
stopped POST /runs/:runId/stop
maxStreamEvents The event cap was reached
maxStreamDurationMs The duration cap elapsed
idleTimeout Nothing arrived for sseIdleTimeoutMs

The whole-transfer timeout is deliberately not applied to a stream - it would kill a healthy one mid-flight. The only deadline is the idle one, and the two caps above are what bound a stream that talks forever. maxStreamDurationMs (1000–86400000) and maxStreamEvents (1–10000000) override the configured defaults per request; sending either without "stream": true is a 400, since a cap that quietly did not apply is worse than no cap.

Refused with a 400 rather than silently reinterpreted:

  • a non-boolean stream ('stream' must be a boolean);
  • "stream": true with "transient": true - a stream is its run row: the row is what eventsUrl names, what carries the status, and what a stop finds, and a transient execution creates none;
  • stream on POST /runs (errorCode: "invalid_run_config") - a load run's completion accounting has no place for a response that never ends.

Scripts run on a streaming request (issue #575), and were refused until they did. The pre-request script runs before the transfer starts, exactly as on a buffered send, so its pm.request edits reach the wire. The post-request script runs once, after the stream has terminated, and reads the bounded stored list as pm.response.events with pm.response.totalEvents and pm.response.eventsTruncated beside it - the sandbox is synchronous with no event loop, so a live per-event callback is not a feature that was skipped but one the runtime cannot have. See Scripting.

Because the route has already answered 202, a streaming run's script output has nowhere to be returned: the trace's scripts node is the only route it takes. That node is not a streaming feature - every design send stores it (issue #725), with the same four keys the buffered response body carries (testResults, consoleLogs, preScriptError, postScriptError). One engine object fills both, so a live pane and a restored one cannot disagree. A run whose scripts said nothing stores no node at all.

Tuning: sseMaxRetainedEvents, sseMaxEventBytes, sseMaxStoredEvents, sseMaxStreamDurationMs, sseMaxStreamEvents and sseIdleTimeoutMs (see GET /config).

transient runs the request without recording it (issue #382). The execution is otherwise identical - same composition, the cookie jar named by environmentId, the same scripts, the same response body - but the engine creates no run row: nothing appears in GET /runs, no result trace is written, and the count-based retention prune does not run, so no existing run is evicted. Because the trace is where the post-auth request headers would have landed, a transient execution is also the only way to send with resolved credentials and leave none of them on disk.

Absent and null both mean false; a present non-boolean is a 400 ('transient' must be a boolean) rather than a silent false, because a caller that asked for privacy must not be quietly refused it. The flag is not valid on POST /runs - a load or scenario run is the row it creates (the run id is the endpoint's return value), so sending it there is a 400 with errorCode: "invalid_run_config".

The one caller today is the app's GraphQL schema introspection (lib/graphql/introspect.ts), a background fetch the user never made. MCP's run_request deliberately does not set it: an agent's runs belong in History like anyone else's, and the tool builds its payload from named arguments, so an agent cannot supply the flag either.

data binds one row to this send (issue #601). It is the single-send half of a run's scenario.data: every {{data.column}} in the URL, the header names and values, the body and both halves of every form field is substituted against it, and both scripts read it as pm.iterationData with pm.info.iteration 0 and pm.info.iterationCount 1 - the send is row 0 of 1. Without the field nothing changes: {{data.*}} goes out written as it stands and pm.iterationData is undefined.

The row's own keys are also its bound bare names (issue #1007): the send derives its dataColumns set from data's keys itself, so a bare {{username}} binds from the row exactly as {{data.username}} does, at the same precedence D18 gives it. A bare name the row does not carry is not deferred in the first place - it is an ordinary {{name}}, resolved from the scopes at compose time or, if nothing there answers either, by the residual pass after the pre-request script (issue #1008).

An object of name/value pairs, never the array a run sends - one row. The row is bounded by maxScenarioDataBytes (the same setting a run's whole set is measured against) and composes freely with transient and stream: a streaming send binds the row before the transfer opens, so the URL and headers it opens with are the bound ones.

Refused with a 400, before any run row exists and with nothing sent:

What Message
data is not an object 'data' must be an object of name/value pairs (got array). A single send binds one row; a set of rows is a collection run.
over the byte cap 'data' is N bytes, over the limit of M (raise the 'maxScenarioDataBytes' setting to allow more)
a token names a column the row lacks the binder's own sentence, naming the token, the row and the row's columns
a null cell, a header collision, a header name bound to nothing, an unwritable XML placement the binder's own sentence - identical to a run's, see Scenario runs
an OAuth 2.0 config carries a {{data.*}} token Auth credentials carry {{data.client}} in an OAuth 2.0 configuration, and no row can reach it: ...

Credentials bind here too (issue #642). A {{data.user}} in a basic-auth username, a bearer token or an api key is substituted from the row like any other field, and it is bound before the credentials are encoded - so basic auth base64s the row's values, and an api key in the query is percent-encoded after the substitution rather than before. This is the same deferral a collection run performs per iteration (issue #591): the credentials are parsed and kept typed, the request is built without resolving them, and the auth is applied once the row has reached it.

OAuth 2.0 is the one mode a row cannot reach, and it is refused by name rather than sent wrong. Its token is acquired against the token endpoint instead of being written into the request, so there is no moment at which a bind could happen - exactly the reason a scenario run refuses the same config. Use a static credential there, or move the data token into the request itself.

requestName is script identity, not an HTTP field - it never reaches the wire. The scripts read it as pm.info.requestName (with requestId as pm.info.requestId); a client sends it because Send executes editor state, which may be unsaved and therefore have a name no stored row carries. Absent, the engine falls back to the name of the row named by requestId, so linking an id is enough. An empty string is treated as absent - a script reads undefined rather than "" - and a non-string is a 400 ('requestName' must be a string). See scripting.md.

allowScriptRequests lets this payload's scripts use pm.sendRequest. Absent, null or non-boolean all mean false, and that default is a security control rather than a convenience (issue #302): Vayu's MCP target allowlist is checked in the MCP server, before it calls this endpoint, so a request issued from inside a script never passes that gate. Denying unless a caller asks means a client that forgets gets a script that cannot send rather than unchecked egress. The app's Send and load runs send true; the MCP server never does. POST /runs reads the same field for a script.post element's deferred validation (its requestElements, below), so one script behaves the same on both. Read before the stream branch, so a streaming send's scripts are governed by it exactly as a buffered send's are - the app sends it on both halves of Send (issue #653). See scripting.md.

elements is the only script source, and it runs (issue #1514's cut-over, replacing #1513's preRequestScript(s) / postRequestScript(s)). A caller still sending any of preRequestScript, preRequestScripts, postRequestScript, postRequestScripts or tests - even naming one with null is accepted as a no-op, but a real value is not - is refused with a 400 naming elements as the replacement, before any run row exists. For the by-id path, an element list a caller supplies inline is laid over POST /compose's own resolved chain (collection root to leaf, then the request's own, minus anything disabled or named by an inherit.disable entry, each stamped with origin: {kind: "collection" | "request", id, name?}) exactly as any other inline field overlays the stored one; an inline-only send has no chain to resolve one from, so its elements carry no origin.

vayu::core::ElementPipeline runs the compiled list at each phase this send reaches: script.pre elements at step.before, immediately before the transfer (so a pm.request edit reaches the wire); extract.* / assert.* / script.post elements at step.after, once the response (or, for a streaming send, the terminated stream) is in. assert.* outcomes append to the same pm.test() channel a script.post element's assertions do, so the response body's testResults and consoleLogs (see below) do not distinguish a declarative assertion from one a script wrote by hand. The response body and the stored trace both carry an elements array beside scripts - one entry per element that ran, each {id, kind, origin, outcome, message?, waitedMs?, wrote?} with outcome one of ok | failed | missing | skipped | error (a disabled element reports skipped without its apply ever running) - see Elements.

The pre-request script can change what is sent. Its pm.request edits - method, url, headers, body - are applied to the request before it goes out, and because auth is resolved before the script runs, a script-set Authorization overrides the one the engine applied. requestHeaders and rawRequest in the response below, and the stored trace behind GET /runs/:id, all report the post-script request. A value the engine cannot send (a non-string url, an unknown method) rejects the whole write-back, leaves the request unchanged, and is reported as preScriptError. See scripting.md.

Redirect policy. followRedirects defaults to true, so omitting it follows every 3xx and only the final response is returned - send followRedirects: false to see the 3xx status and its Location header. Both clients send these explicitly for exactly that reason (see api-integration). POST /runs accepts the same three fields with the same defaults, so a load test can be run under the policy the request was configured with.

TLS verification. verifySSL defaults to true and is stored on the request (requests.verify_ssl, the Settings tab's Verify TLS certificate row). Both clients send it on every execute and every load test rather than eliding the default, for a stronger reason than the redirect policy above: an omitted false verifies the certificate the user turned verification off for, so the request fails against the one host the setting exists for. Off skips both the chain and the hostname check (CURLOPT_SSL_VERIFYPEER and CURLOPT_SSL_VERIFYHOST), which is why the app paints the state as a warning. To keep verification on for a host with an internal authority, add the CA under TLS trust settings instead.

A redirect that crosses origins drops the cookies the hop set. Since curl 8.21.0 (http: don't pass on set cookies to new origins, which the engine picked up with the vcpkg baseline bump of #679), a Set-Cookie returned by one origin is not applied to the follow-up request when the Location points at a different one; a same-origin hop is unchanged. This is upstream security hardening, inherited deliberately, and it is not configurable - the engine sets CURLOPT_FOLLOWLOCATION per request wherever followRedirects is true, so it holds on POST /execute, on load runs and on streaming requests alike. A flow that depended on a cookie surviving a cross-origin hop (a login that bounces through an identity provider is the usual shape) now sends the follow-up request without it. Nothing about the cookie jar itself changed: it is domain-scoped as before, so a cookie is still stored under the origin that set it and still sent on a later request back to that origin.

Protocol. httpVersion selects which HTTP version curl attempts: "auto" lets ALPN negotiate (curl's own default), "http1.1" forces HTTP/1.1, and "http2" attempts h2 over TLS and falls back to 1.1 (CURL_HTTP_VERSION_2TLS - against a plain http:// URL this silently negotiates 1.1, since h2 is not offered over cleartext). This is what was requested; the response's own httpVersion (below) reports what was actually negotiated, and the two can differ. The renderer always sends this field on every execute, never eliding it even when it equals the default - the same rule followRedirects follows, and for the same reason: an omitted field lets an engine-side default win silently. MCP's saved-request paths get the same guarantee from POST /compose, which always emits a stored request's execution options; its two ad-hoc tools (run_request / start_load_run) forward httpVersion only when the caller supplies it, since there is no saved request behind an ad-hoc call for an omission to silently override (see mcp.md). It governs both Send and load test - POST /requests/PUT /requests/:id is where a request's protocol is actually stored (see Requests above); POST /runs (below) is simply the run-shaped way of stating the same field, not a second store.

Response:

{
  "status": 200,
  "statusText": "OK",
  "headers": {
    "content-type": "application/json"
  },
  "requestHeaders": { "accept": "*/*" },
  "rawRequest": "GET /users HTTP/1.1\n...",
  "body": { "id": 1, "name": "John" },
  "bodyRaw": "{\"id\":1,\"name\":\"John\"}",
  "bodySize": 20,
  "bodyCapped": false,
  "httpVersion": "HTTP/1.1",
  "httpVersionDowngraded": true,
  "timing": {
    "totalMs": 245.5,
    "wireMs": 245.1,
    "queueWaitMs": 0.4,
    "dnsMs": 5.2,
    "connectMs": 12.3,
    "tlsMs": 45.1,
    "firstByteMs": 180.2,
    "downloadMs": 2.7
  },
  "testResults": [
    {
      "name": "Token was issued",
      "passed": true,
      "source": "pre"
    },
    {
      "name": "Status is 200",
      "passed": true,
      "source": "test"
    }
  ],
  "consoleLogs": [
    { "source": "pre", "level": "log", "message": "token refreshed" },
    { "source": "test", "level": "error", "message": "unexpected shape" }
  ],
  "validation": {
    "checked": true,
    "valid": false,
    "matchedStatus": "200",
    "matchedContentType": "application/json",
    "failures": [
      { "path": "/id", "message": "Value type not permitted by 'type' constraint." }
    ],
    "failuresTotal": 1,
    "unevaluatedKeywords": [{ "keyword": "unevaluatedProperties", "count": 2 }]
  }
}

bodyCapped says the body is a prefix (issue #1157). A design-mode send reads at most maxDesignResponseBodyBytes (Settings → Limits, default 32MB); a response past that stops being read there, and body / bodyRaw / bodySize describe what arrived rather than what was sent. The status and the headers are the server's own - they arrive before the body - so this is a successful response carrying a flag, not a transport failure. The field is always present, so a client can tell "not capped" from an engine too old to say, and re-sending under the same setting reads the same amount: raise the setting to read more. Two consequences worth stating, since both follow from reading less: a cut JSON body no longer parses, so body is null and a test script's pm.response.json() throws, and validation below reports checked: false with body_not_json rather than inventing a schema failure.

This is not the stored trace's bodyTruncated (see GET /runs/:id), which is maxTraceBodyBytes shortening a body for storage after the whole of it was read and shown - a re-send recovers from that one. A design run whose live response was capped stores trace_data.response.bodyCapped, so a restored response says what the live one said.

validation is what the response was against the schema its contract declares (issue #628), and it is absent entirely for a request whose collection ancestry binds no OpenAPI document. That absence is load-bearing: a response nobody judged against a contract did not fail one, so there is no checked: false for it either.

The same object is stored on the design run's trace_data.validation, so a restored response shows the verdict the live one did rather than recomputing it. A streaming send carries none - an event stream is not a document a response schema describes.

testResults lists both scripts' assertions in execution order, each naming the script that made it in source ("pre" / "test", the spellings consoleLogs uses) - pm.test is bound in both phases, and until issue #810 a pre-request assertion failed a collection-run step while appearing in no list. An entry stored before that carries no source; it is a test script's, which is all the list held.

The four script keys are stored the same way (issue #725). testResults, consoleLogs, preScriptError and postScriptError are returned in the body above and written to the design run's trace_data.scripts as one object, so a response restored from History carries the assertions the live one showed. This used to hold for a streaming send only, which made a restored ordinary send's Tests pane empty whether its assertions passed or never ran. A send whose scripts said nothing stores no scripts node at all - absent means no scripts, not no results - and a transient execution stores neither, as it stores nothing.

Field Meaning
checked Whether anything was actually validated
valid Present only when checked. A body with no failures
reason Present only when not checked - the code, see below
failures[] path (a JSON Pointer into the body) and message, capped at 10
failuresTotal Every failure found, including any past the cap
unevaluatedKeywords[] Schema keywords the validator could not evaluate, by name and count
matchedStatus / matchedContentType Which declared response answered, verbatim

A status answers to the most specific pattern that covers it - exact, then 2XX, then default - the same rule contract coverage counts by, shared rather than restated. A media type matches exactly first, then a declared */* or application/*.

unevaluatedKeywords is the dialect disclosure and is not decoration. The validator reads draft-07; OpenAPI 3.1 schemas are JSON Schema 2020-12, whose unevaluatedProperties, prefixItems and dependentSchemas a draft-07 reader silently ignores - so a schema that meant to forbid something would permit it. Every such keyword is named and counted, which makes a valid: true beside one narrower than it looks rather than wrong.

reason codes (all mean bound to a document, and still not judged):

Code What happened
no_operation The request carries no spec_operation - it is not an operation
no_index The bound document carries no response schemas, or its schema could not be read
hash_mismatch The stored document no longer hashes to what the binding recorded
never_stamped The binding names a document and no version of it, so nothing can be compared - re-bind the collection. Distinct from hash_mismatch because a sync of an unchanged document would not repair it
operation_not_declared The document does not declare this identity
no_schema_for_status Nothing the operation declares covers this status
no_schema_for_content_type The status matched; none of its media types did
no_response A transport error - there was no response to check
body_not_json The body is not JSON, and a JSON Schema cannot describe it

headers is keyed by the lower-cased header name, and a name the response sent more than once holds every value folded with ", " - the RFC 7230 §3.2.2 equivalence for comma-list headers. So two Set-Cookie lines read back as one "session=abc; Path=/, csrf=xyz; Path=/" entry rather than the last one alone. A client splitting a folded Set-Cookie must split on a comma followed by name=, since an Expires= value contains a comma of its own. The same holds for the response headers stored on a design run's trace_data.response and on captured load-run samples, which come off the same parse.

rawRequest is the header block libcurl actually sent, captured from the transfer's last outbound header frame and followed by the request body. So it carries what libcurl added on its own - the Cookie line the cookie jar matched, Accept, Content-Length, and an h2 request rendered in HTTP/1 form - none of which appear in requestHeaders.

requestHeaders is the sent record: the composed headers as the transfer issued them. It carries what the engine derives at send time - the body-implied Content-Type, and every default header this send adds (the User-Agent, the negotiated Accept-Encoding, an enabled correlation id) - and drops a form-data Content-Type, which libcurl writes itself with the boundary. It also drops an enabled header whose value is empty or only whitespace: a header line with nothing after the colon is libcurl's spelling for remove this header, so such a row never reaches the wire on any transport and is not reported as sent either. To send a header that is present with an empty value, give it a value - the engine does not emit libcurl's Key; form. It does not carry libcurl's own additions or the jar's Cookie line; those are rawRequest's alone. A test script's pm.request.headers reads this same set (see scripting.md), so an assertion about what went out and the response pane's Headers tab cannot disagree.

The stored trace keeps this map too, as trace_data.request.sentHeaders beside the composed request.headers (issue #664) - so a restored response pane's sent-headers disclosure shows the set the live one showed, derived User-Agent and body-implied Content-Type included, rather than the composed map's different answer. Both maps are stored because they answer different questions: the composed one is what a pre-request script saw and what reseeds a request tab from a run. The key is absent on a step that sent nothing and on every row written before the field, so a reader falls back to request.headers - restore-response.ts's sentSide is that reader. A load run's sampled capture records no sent headers at all: its deferred replay and the Samples viewer both keep reading the composed map, as scripting.md documents. That map can differ from the wire in exactly the two ways this record exists to state - a value-less enabled header is listed although it never went out, and the derived User-Agent and body-implied Content-Type are absent - and for load samples that divergence is recorded as permanent rather than treated as a gap to close (issue #677 item 7). Which completions become samples is decided when they finish, so a sent record for the few that are kept would have to be built for every transfer, and a load run exists not to charge the hot path for what it throws away.

Values in rawRequest are not redacted: this field exists to say exactly what went out. On a followed redirect it is the final hop, matching the response beside it. A transfer that failed before sending anything (DNS failure, connection refused) has no frame to read, and falls back to a request synthesized from what was composed.

The stored trace keeps the same string, as trace_data.request.rawRequest on the design run this execute created (and on each step row of a scenario run) - so reopening a run shows the raw request the live view showed, cookies included, rather than one rebuilt from headers that never had them. Its body half is capped at maxTraceBodyBytes like body is, and the key is absent both on a step that sent nothing and on rows written before the field existed; see db-schema. The redaction posture is the live field's, for the reason Security records: a trace is the record of what was sent.

consoleLogs entries carry their own source and level. source is which of the request's two scripts wrote the line ("pre" for the pre-request script, "test" for the post-request one) and level is the console.* method that was called - "log", "info", "warn" or "error". Releases before this one sent a flat string[] with the source encoded as a "[pre] " text prefix and no level at all, which was indistinguishable from a script logging that string itself; a client that may talk to an older engine should read a bare string as {"source": "test", "level": "log"}, or "pre" when the prefix is present. The field is omitted entirely when neither script logged anything. See scripting.md.

httpVersion on the response is the protocol actually negotiated (CURLINFO_HTTP_VERSION after the transfer), e.g. "HTTP/1.1" or "HTTP/2" - an outcome, not an echo of the request's own httpVersion field, and deliberately a different value space (see requests.http_version for the full distinction). It is "", not omitted, when nothing was negotiated (e.g. the connection never reached a server) - empty rather than guessing "HTTP/1.1" and presenting a guess as fact. The same field is stored in a design run's trace_data.response and reported back unchanged by GET /runs/:runId and GET /runs/:runId/report.

httpVersionDowngraded is true when the request explicitly asked for "http2" and the connection negotiated something older - the one thing the two httpVersion fields cannot say on their own, since neither knows about the other. It is always present (never omitted), so a client can tell "not downgraded" from "an engine too old to say". Only an explicit http2 counts: "auto" promises nothing and "http1.1" got what it asked for, so neither can be downgraded. A transfer that negotiated nothing at all (httpVersion "") is false - that is a transport failure, and errorCode already reports it.

A plaintext http:// URL always reports true for an explicit "http2". CURL_HTTP_VERSION_2TLS offers h2 over TLS only, so a cleartext request never attempts it - the fallback noted above under the request's httpVersion is exactly the case this field is for, and it is honest rather than a false positive: h2 was asked for and HTTP/1.1 was used. A local dev server on http:// with the protocol set to HTTP/2 will show the warning on every request; either switch the request to auto, or serve over TLS.

This exists because the failure it names is invisible otherwise: a 200, a latency and a body look identical whether or not the protocol you asked for was granted. Windows shipped from v0.11.0 to v0.14.0 with HTTP/2 unreachable and every request silently on HTTP/1.1 (#215); nothing in the API said so. The same field is stored on a design run's trace_data.response, and load runs carry the whole-run count as summary.httpVersionDowngraded in GET /runs/:runId/report.

One timing convention. The timing keys above are the same *Ms names the stored trace uses (store_result / load_strategy → results[].trace in GET /runs/:runId/report), and the design-mode writer stores all eight keys unconditionally - so a live response and one restored from the stored trace carry the same fields with the same names, Wire/Queue included. Traces written by earlier releases differ two ways, and readers must tolerate both: stored rows omitted zero-valued phases and all of totalMs/wireMs/queueWaitMs (see db-schema.md), and the live response named its keys without the suffix (firstByte, dns, …) - consumers of the raw /execute body written against that dialect must switch to the *Ms names.

Variables the scripts wrote are persisted, and only those. After the post-request script runs, the engine writes back the three variable scopes (the run's environment, globals, and the executed request's collection) - but only for a scope whose variables a script actually changed. A run that sets no variable writes nothing at all, so it does not move a collection's or environment's updatedAt. Each variable round-trips whole, createdAt included; see VariableValue shape for why that field must survive and who may stamp it.

POST /runs

Start a load test run (Vayu Mode).

Alias: POST /run (deprecated - see Deprecated aliases).

Request:

{
  "method": "GET",
  "url": "https://api.example.com/users",
  "headers": {},
  "body": {
    "mode": "none",
    "content": ""
  },
  "mode": "constant_rps",    // "constant_rps", "constant_concurrency", "ramp_up", "iterations", or "capacity"
  "concurrency": 100,        // Target in-flight requests (constant_concurrency / ramp_up target / iterations); the ceiling for capacity
  "startConcurrency": 1,     // Ramp start concurrency (ramp_up); first level searched (capacity)
  "duration": "60s",         // Duration, ms/s/m/h (constant_rps / constant_concurrency / ramp_up); the deadline for capacity
  "rampUpDuration": "10s",   // Ramp time, ms/s/m/h (ramp_up mode; start may be above target)
  "sloMs": 200,              // p99 budget the search looks for the edge of (capacity mode)
  "stepDuration": "5s",      // How long each level is held before it is judged (capacity mode)
  "iterations": 0,           // Number of iterations (iterations mode)
  "targetRps": 1000,         // Target requests per second (constant_rps mode)
  "maxInFlight": 10000,      // Optional; see "maxInFlight" note below - constant_rps only
  "requestId": "req_1234567890",      // Optional, links to saved request
  "requestName": "Create user",       // Optional, read by the deferred validation script as pm.info.requestName
  "environmentId": "env_1234567890",  // Optional
  "requestElements": [],     // Optional step-level elements for THIS request - see below
  "data": [],                // Optional data rows, one object per row - see below
  "thresholds": {},          // Optional pass/fail budgets - see below
  "elements": {},            // Optional element-pipeline override, either run shape - see below
  "lifecycleElements": [],   // Optional script.setup/script.teardown for THIS run only - see below
  "monitor": {},             // Optional server-vitals scrape - see below
  "followRedirects": true,   // Optional, default true - see POST /execute
  "maxRedirects": 10,        // Optional, default 10
  "httpVersion": "auto",     // Optional: "auto" | "http1.1" | "http2", default "auto" - see POST /execute
  "stream": false,           // Optional - consume each response as text/event-stream; see below
  "maxStreamDurationMs": 600000,  // Optional, streaming only - see below
  "maxStreamEvents": 100000,      // Optional, streaming only - see below
  "stream_metrics": true     // Optional - feed the event histogram behind the report's `stream` section
}

httpVersion on POST /runs is not a per-run override of a stored request - it is simply how this endpoint states which protocol the run uses at all, the same way method and url state the rest of the request. The renderer always sends the saved request's own httpVersion here (there is no second, load-test-only protocol control in the app); MCP's ad-hoc start_load_run - which has no saved request behind it - is the caller that actually depends on this field to specify a protocol in the first place. An explicit null is treated exactly like an absent key. An unrecognized string is a 400 naming the field and the valid values, the same validation POST /requests uses.

Streaming under load (stream)

POST /runs reads stream, maxStreamDurationMs and maxStreamEvents through the same parser POST /execute uses, so the two endpoints agree on the spelling, the types and the ranges - a load run declares a stream exactly as a send does. It was a 400 here until this landed, because a load run's completion accounting has no place for a response that never ends. What changed is that a load stream always ends.

Under load a stream is bounded by construction. Both caps are always in force - the payload's, or the sseMaxStreamDurationMs / sseMaxStreamEvents settings when the payload names none - and neither can be set to zero-for-unbounded. That is not tidiness: the load loop is completion-driven (in_flight() = sent - completed, concurrency refilled per completion), so a transfer that never completes leaks its slot for the rest of the run.

Reaching a cap is a successful completion, not a timeout and not an error: it is the stream's intended end under load, so it lands in the run's success counts, its latency histogram and its status-code distribution like any other 200. Three other endings stay exactly what they were:

Ending Reported as
A cap in maxStreamEvents / maxStreamDurationMs Success, stream.capped incremented
The server closing the stream Success, not counted in stream.capped
maxResponseBodyBytes exceeded Error - a refusal to buffer, unchanged. The event cap bounds how many events arrive, not how large one is
The whole-transfer timeout Error - it sits a grace period past the duration cap, so reaching it means the cap never fired

Events are counted on the write path by a frame counter that agrees with the design path's parser about what an event is: a frame carrying no data field is not one, so comment-only keep-alives do not inflate the tally.

stream is refused beside transient (as on a send) and each cap is refused without stream - a cap on a non-streaming run reads as a bound the caller expects to apply, and ignoring it is how an unbounded run gets mistaken for a capped one.

Streaming is not supported on a scenario run: scenario composes its steps at plan time and each step is its own request, so there is no single stream for the caps to bound.

The data rows (a data-driven load run)

A single-request run may carry a top-level data array - one flat object per row - and bind it into the request it repeats (issue #993):

{
  "method": "POST",
  "url": "https://api.example.com/orders/{{data.id}}",
  "mode": "constant_concurrency",
  "duration": "30s",
  "concurrency": 50,
  "data": [
    { "id": "1", "email": "ada@example.com" },
    { "id": "2", "email": "grace@example.com" }
  ]
}

One row per request sent, claimed off a run-wide cursor that wraps: a run longer than the set repeats it from the top, so the file bounds which values go out and never how many requests do - that is the load profile's job. The row a completion was bound to is recorded as dataRowIndex on every retained result, and the deferred tests script reads that submission's own row as pm.iterationData.

Everything else is the scenario path's, because it is the same code: the same validation (an array of objects, present-but-empty refused, bounded by maxScenarioDataRows / maxScenarioDataBytes), the same {{data.column}} placement rules and escaping, the same credentials-bind-before-they-are-encoded order, and the same refusal of a {{data.*}} in an oauth2 config. Every rejection is a 400 invalid_run_config before the run row exists, so a refused set leaves nothing behind.

Two differences from scenario.data worth stating:

  • iterations is not defaulted from the row count. A load profile already says how long the run is; a collection run without an explicit count takes one pass per row.
  • The two fields cannot be combined. A top-level data beside a scenario block is a 400 naming scenario.data - the two bind differently (per submission here, per iteration and shared by every step there), so a payload carrying both would have one of them silently dropped.

The rows are never persisted: the stored run snapshot carries dataRowCount in their place, exactly as a scenario manifest does. A cell that binds travels in the request that carried it, so it is stored with whatever traces the run retains.

The thresholds block (pass/fail budgets)

A run may declare budgets it must meet. The engine evaluates them once, when the run reaches a terminal status, and the report comes back with a thresholdValidation section carrying one check per budget and a verdict. Without the block a run is measured and not judged, and its report has no such section at all.

{
  "thresholds": {
    "latencyP50Ms": 20,        // ceiling, ms; > 0 and <= 86400000
    "latencyP95Ms": 40,        // ceiling, ms
    "latencyP99Ms": 50,        // ceiling, ms
    "maxErrorRatePct": 0.1,    // ceiling, percent of the run's requests; 0-100
    "minThroughputRps": 10000, // floor, completed requests per second; > 0
    "maxAssertionFailureRatePct": 0, // ceiling, percent of assert.* and pm.test outcomes; 0-100
    "failRun": true            // a missed budget sets status "failed"; default false
  }
}

Every key but failRun is a budget; at least one budget must be present (a thresholds object holding only failRun is rejected the same way an empty one is - it names nothing to judge). An unknown key, a non-numeric or out-of-range budget value, a non-boolean failRun, or an object that declares no budget is a 400 invalid_run_config naming the field - and, like every other run-config rejection, it happens before the run row is created, so a rejected request leaves no trace. A null value reads as absent, the same rule the flat numeric fields follow.

maxErrorRatePct is measured against every response outside 2xx/3xx plus the transport failures that never got one - the same figure summary.errorRate reports. This is deliberately wider than the script-level pm.test view: a run of nothing but HTTP 500s has a transport error rate of zero.

maxAssertionFailureRatePct (issue #1497) is measured against this run's combined assertion tally: every assert.* element outcome and every pm.test call, however the script that made it ran - inline on the load path or through the deferred replay. Unevaluated, like a latency percentile, when the run made no assertion at all - a run with no assert.* element and no test script is unaffected by declaring this budget.

Every run mode is judged (issue #1564): a single-request load run, a scenario load run and a collection (sequential, design-mode) run all evaluate the same thresholds block against their own numbers and store the same thresholdValidation shape. A collection run's error rate and latency percentiles are drawn from the steps it actually sent (a step a script or an unbound data row skipped counts toward neither), and its assertion tally is the same combined assert.*/pm.test count maxAssertionFailureRatePct reads for a load run.

custom.<name>.<stat> (issue #1500) budgets a named metric.record / pm.metrics value - <name> the metric was recorded under, <stat> one of p50, p95, p99, max (a trend), value (a counter's running total or a rate's percentage, whichever the name was declared as) or rate (the same field as value, spelled for a rate metric):

{ "thresholds": { "custom.ttfb2.p95": 50 } } // ceiling, always - >= 0

Unlike the six fixed keys, this is a dynamic key family: the route checks the shape (custom. prefix, a non-empty name, a stat from the list above) and that the value is a non-negative number, not that ttfb2 is a name this run's plan actually declares - a metric.record on a step the run never reaches, or a plain typo, is not distinguishable from a not-yet-recorded metric at validate time. Evaluated the same way an unmeasured latency percentile is: evaluated: false when this run's collector never recorded that name, counted toward failed rather than a silent pass. Every run mode is judged, the same rule the six fixed keys follow (#1564): execute_scenario_run folds summary.custom_metrics into the same RunSummaryInputs it evaluates thresholds against for a collection (sequential, design-mode) run, not only a load run.

The verdict is the run's, not the process's: a run stopped early is judged on what it measured up to that point, and its status stays stopped whatever the verdict says. A completed run whose config set thresholds.failRun: true and whose budgets it missed ends failed instead of completed; without the flag, or on a run that met every budget, a failing budget is reported but never changes the terminal status.

Each check carries evaluated. A latency percentile needs a completed request to mean anything, so a run that recorded none for that metric (every request errored before a response arrived) reports evaluated: false and omits actual entirely rather than the default 0, which would otherwise read as a measured 0ms and trivially satisfy an "at most" ceiling. An unevaluated check counts toward failed: a budget the run could not measure was not met. The error rate and the throughput floor have no such gap - maxErrorRatePct is 0/0-safe by request count, and a starved minThroughputRps already fails on its own - so both are always evaluated: true.

The elements block (element-pipeline override)

A scenario load run's elements block overrides how the element pipeline runs across every step of the run (issue #1495), without editing the collection itself.

{
  "elements": {
    "timers": "asConfigured",     // "asConfigured" (default) | "off" | {"fixedMs": N} | {"minMs": N, "maxMs": N}
    "scripts": "asMarked",        // "asMarked" (default) | "allInline" | "allDeferred"
    "includeScriptTime": false,   // default false
    "seed": 42                    // Optional, non-negative integer - seeds this run's RNG
  }
}

Every key is optional; an unknown key or an out-of-set value is a 400 naming the field, the same invalid_run_config shape the scenario block's own unknown-key rule uses, checked before the run row is created.

  • scripts decides whether a script.pre / script.post element runs inline (on the producer/completion hooks, before or after the step's own send) or stays deferred to the post-run tests replay. "asMarked" (the default) reads each element's own config.inline; "allInline" / "allDeferred" force every script.* element of the run one way, regardless of its own marking. extract.* and assert.* are unaffected - they always run inline on a scenario load run.
  • includeScriptTime folds the element pipeline's own elapsed time into a step's recorded latency when true; by default (false) a step's latency is its transfer alone, exactly as before this issue.
  • timers (issue #1498) is wired end to end: "asConfigured" (default) runs every timer.* element's own stored config unchanged; "off" silences every timer.* element for the run (waitedMs: 0, no sleep); {"fixedMs": N} or {"minMs": N, "maxMs": N} replace every timer.* element's own computed wait with the same fixed value or uniform range, whatever that element's own config says - "replaced, not merged", the same rule scripts above uses. timer.think's step.between phase now dispatches on a scenario load run too (non-blocking, summed into VirtualUser::ready_at_ms), so this override reaches load runs as well as the sequential run. timer.pacing and timer.throughput schedule their wait before that step's ElementContext exists at all (Element::scheduled_ready_delay_ms, see Load paths); every mode reaches them there too, through the run's own copy of the override (SharedScheduleState::timers_override, issue #1620), so a run with timers: "off" defers neither kind under load and a run with "fixedMs" or {"minMs", "maxMs"} schedules their wait from that value rather than the element's own everyMs / targetPerMinute.
  • seed (issue #1498) is an optional non-negative integer that seeds this run's RNG, making a timer.think element's gaussian or uniform-random wait reproducible. A scenario load run derives one independent generator per virtual user from this seed rather than sharing one across worker threads. Omitted, the run seeds from std::random_device as before, and every draw is non-reproducible.

Not reported at the run-summary level: there is no summary.timers aggregate. What a timer.* element waited is per-step, per-element - waitedMs on that element's entry in the step trace's elements array (see The step trace) - not rolled up into GET /runs/:runId / the completion report's summary object.

Not part of this block: a single-request POST /runs payload's step-level attachment point is the separate requestElements array below, not this one - this block is accepted on either run shape (the validator does not distinguish them), and on a single-request run it overrides what requestElements declares, the same way it overrides a scenario's stored elements. The run's own boundary, script.setup / script.teardown, is a third array again, lifecycleElements, further below.

The requestElements array (a single-request run's own step-level elements)

A single-request POST /runs payload's own place to attach step-level elements to the one request it sends (issue #1594): before this, a single-target load run ran no element pipeline at all, so the same request's extract.* / assert.* / timer.think / script.* elements that run in a design send, a sequential collection run, and a scenario load run's steps never ran under a single-target load run. An array of element descriptors, the same shape a request's own stored elements column holds:

{
  "requestElements": [
    { "kind": "script.pre", "config": { "script": "pm.request.headers['X-Sig'] = sign()", "inline": true } },
    { "kind": "assert.status", "config": { "in": [200] } },
    { "kind": "script.post", "config": { "script": "pm.test('ok', function () { pm.expect(pm.response.code).to.eql(200); })" } }
  ]
}

Validated the same way a stored elements column is (Registry::validate per entry, a bad kind or config a 400 naming the index); id and enabled are optional, defaulting to a generated id and true. Refused outright beside a scenario block, whose steps already carry their own resolved elements from the plan. This is the field MCP's start_load_run renames a composed request's chain-then-own elements onto, since POST /compose answers under the plain elements key and this endpoint's own elements key means something else (the override block above) - see mcp.md.

Only the kinds this run shape's own hooks actually dispatch are accepted: extract.*, assert.*, timer.think, script.pre/script.post - the same phase-0 set issue #1514 gave the design send. A control.* kind or timer.pacing/timer.throughput is a 400 naming the index and the kind, never silently accepted and left inert: those need per-VU controller and pacing state (controller_state, SharedThroughputCounters, pacing_state) this run shape's single-submission model has no equivalent of, and admitting one without running it would report "ok" in summary["elements"] for behaviour that never happened. Run a sequence that needs a controller as a "scenario" instead, where its steps carry that state.

vayu::core::ElementPipeline runs the compiled list per submission, the same phases a design send uses: script.pre at step.before, extract.* / assert.* / script.post at step.after. A script.* element runs inline only when marked or forced. Unlike a design send or a sequential collection run, a load submission has no persistent per-VU object to defer a script onto, so a script.pre or script.post element runs inline, per submission, only when its own config.inline is true or this run's elements.scripts override (above) is "allInline"; left unmarked with no override, script.post still runs, but deferred - on the run's completion replay, the same way a plain tests string used to - while script.pre simply never runs at all, because there is no pre-request replay to defer it to. RunContext::script_element_runs_inline is the one place that decision is made, shared with the scenario load path's own step dispatch. timer.think's wait is folded into maintain_concurrency's own backpressure (RunContext::reserve_think_wait) rather than a blocking sleep on a worker thread, so a request-level timer costs no new thread the way the scenario load path's per-VU wait does.

A script.pre element's edits reach the wire, and its scope's writes reach later {{tokens}} in the same submission. When requestElements is non-empty, each submission resolves against a small, request-scoped variable overlay (vayu::http::routes::ScopeOverlay) layered over the run's flattened base scopes, and the residual-token pass (resolve_residual_tokens, Request composition) runs against that view before the transfer - so a pm.environment.set (or a pm.request edit) an inline script.pre makes is visible to any {{token}} compose could not already resolve on that same request, the same guarantee a scenario load run's per-VU overlay gives its own steps. Nothing shares this overlay across submissions - each one gets its own, discarded after - which is what keeps many concurrent submissions from writing through one shared map.

pm.info.requestName is not bound for an inline element on this path. The deferred replay (the un-inlined script.post case, test_script) reads requestName off the run's own config exactly as it always has; an inline script.pre / script.post element's ScriptContext does not carry it, since binding it is the scenario load path's own bind_step_identity call (scenario_load.cpp), which this path has no equivalent of yet. An inline script reading pm.info.requestName sees undefined, not the name a deferred one on the same run would.

The lifecycleElements array (a single-request run's own setup/teardown)

A single-request POST /runs payload's own place to declare script.setup / script.teardown (issue #1573): it has no collection row to declare them on, and the elements block above is a per-step override, not a place to author new behaviour. An array of element descriptors, the same shape a collection's own elements column holds:

{
  "lifecycleElements": [
    { "kind": "script.setup", "config": { "script": "pm.environment.set('token', 'x')" } },
    { "kind": "script.teardown", "config": { "script": "pm.sendRequest(...)" } }
  ]
}

Restricted by kind to the two elements that dispatch at a run's own boundary rather than a step's - any other kind is a 400 naming the index and the kind, not silently ignored or run as if it were one of the two. Each entry's config is validated against that kind's own schema (the same Registry::validate a collection's elements column is checked against); id and enabled are optional, defaulting to a generated id and true. Refused outright beside a scenario block - a scenario collection already has a real elements column for this, so declaring both would be two sources of truth for the same run boundary.

script.setup runs once, before the run's first submission (before test_start is captured, so its own time is never folded into the run's duration figures); a throwing setup fails the run - Failed, nothing sent - the same as a collection-backed run. script.teardown runs once, after the last submission settles, and sees pm.info.run (requestsSent, errorRate, assertionsPassed, assertionsFailed); a throwing teardown is recorded under the report's lifecycle.teardown and never changes the run's terminal status. Both read and write the same variable scopes every other script of the run shares - the environment named by environmentId, and the collection scope of the request requestId links, when the run links one; a bare URL run with no requestId gets no collection scope. Unlike a collection-backed run, script.setup / script.teardown alone do not make those scopes reach the request: the residual-token pass a requestElements entry gets (see above) only runs when requestElements is itself non-empty, because that pass reads the same base scopes run_collection_setup only loads once one of the two arrays is present, and lifecycleElements alone still leaves method / url / headers / body reading nothing back. So a pm.environment.set in script.setup reaches a later {{token}} only when the run also declares requestElements; with lifecycleElements alone, pm.sendRequest (external side effects) and the lifecycle outcomes in the report are the only things observable from this run shape's own script.setup/teardown. See elements.md for the shared dispatch mechanism a collection-backed run uses for the same two kinds.

The monitor block (server vitals)

A run may name a metrics endpoint on the target, which the engine scrapes for the life of the run on its own thread. The samples are stored per run and served by GET /runs/:runId/monitor, streamed live as monitor frames on GET /runs/:runId/live, and summarised in the report's monitor section. Without the block nothing is scraped and none of those three carry anything.

{
  "monitor": {
    "url": "http://localhost:9100/metrics",  // required; http(s), loopback and private allowed
    "intervalMs": 1000,                      // optional, defaults to `monitorIntervalMs`; 250-60000
    "format": "prometheus",                  // optional, default "prometheus"; or "json"
    "series": [                              // required; 1 to `monitorMaxSeries` names
      "node_cpu_seconds_total",
      "process_resident_memory_bytes"
    ]
  }
}

format decides how the body is read:

  • prometheus - the text exposition format. Comment and blank lines are skipped, a trailing exposition timestamp is ignored, and a value that is not a finite number (NaN, +Inf) is dropped. Samples sharing a name across label sets are summed, so node_cpu_seconds_total{cpu="0"} and {cpu="1"} chart as one series.
  • json - a flat object of numbers, where series lists the keys to read. A key that is absent or non-numeric is skipped.

A name the body does not carry is absent from that sample rather than zero, and a scrape that reads nothing at all - a transport failure, an unreadable body, or a body carrying none of the requested names - stores no row and is counted as a gap in the report's monitor.failures. A failing scrape never fails the run; after five consecutive failures the engine logs once and backs off to twice the configured interval until one succeeds.

Three of the limits are settings rather than constants: intervalMs defaults to monitorIntervalMs when the block omits it, the series ceiling is monitorMaxSeries, and how long a single scrape may take is monitorScrapeTimeoutMs (see GET /config). All three are read per run, so a change applies to the next run started - no restart. The interval bounds are fixed, because a cadence below 250ms measures the scraper rather than the target and one above a minute records nothing on a short run.

The scrape budget has no field on the block: it is about the endpoint being scraped, which is the same one run after run, so it lives with the other engine settings. Left at its default of 0 it tracks the cadence at three quarters of it - raise it when a heavyweight /metrics renders too slowly for that, and the cadence stays where you set it.

Loopback and private addresses are deliberately allowed: this is a local tool scraping the user's own infrastructure. An unusable block (no url, a non-http(s) scheme, no series, more than monitorMaxSeries, an out-of-range intervalMs, an unknown format) is a 400 invalid_run_config naming the field, before the run row is created.

The scenario block (collection runs)

A run may instead state its work as an ordered collection - the collection runner's sequence primitive. The block replaces the single request, so a payload carrying it needs no top-level method / url, and states its iteration count inside the block rather than through mode / duration / iterations:

{
  "scenario": {
    "source": "collection",         // required; the only accepted value today
    "collectionId": "col_123",      // required
    "recursive": false,             // optional, default false - descend into sub-collections
    "iterations": 1,                // optional; defaults to the data row count, else 1
    "data": [ { "user": "a" } ]     // optional inline rows; see maxScenarioDataRows
  },
  "environmentId": "env_123"        // optional, and what {{variables}} resolve against
}

The collection is resolved into an ordered, fully composed plan once, before anything is sent, in the order the sidebar displays top to bottom: direct requests by requests.order and - with recursive - descendant collections by collections.order, depth-first, each sub-collection's whole subtree running before its parent's own requests, and each list under the tiebreak in Ordering. Subfolders ahead of own requests is the tree's rule, not the column's: a folder's sub-collections and its requests are separately ordered blocks, so the two order values are free to collide, and the run follows the render (issue #431). Each step is composed through the same path POST /compose uses, so a step's request and joined scripts are byte-identical to what a Send of that request would run. A collection edited mid-run therefore cannot change the sequence underneath itself, and no execution path re-reads the database for request data.

A valid block answers 202 {runId} and creates a run with type: "scenario" - unless a load mode sits beside it, which makes it a scenario load run with type: "load" instead. The lifecycle is a load run's either way - the run is registered, streams over GET /runs/:runId/live, stops through POST /runs/:runId/stop and reports through GET /runs/:runId/report - and only the executor differs. See Scenario runs below for what it does while it runs and what it leaves behind.

Every rejection is a 400 with error.code: "invalid_scenario", raised before any run row exists - an empty or oversized sequence is never silently run as a smaller one:

Input Rejected because
source absent, or anything but "collection" The discriminator exists for a future stored scenario; an unknown value must not fall through to the collection path.
collectionId absent, not a string, or empty There is nothing to resolve. The id is echoed back when it names no collection.
The collection (with recursive applied) has no requests An empty sequence is a mistake, not a zero-step run.
A step's composition fails The message names the step index, request name and id.
More steps than maxScenarioSteps The whole plan is held in memory for the run's life; the message carries the count and the cap.
recursive present and not a boolean
iterations present and not a whole number in 1-2147483647 It is read as a count, so 0, a fraction and a string are each a run nobody asked for.
data present and empty A data set that binds nothing is a mistake - omit the field to run without one.
data not an array, or a row that is not an object
More data rows than maxScenarioDataRows The message carries the count and the cap.
A data array larger than maxScenarioDataBytes The row count cannot catch a few very large rows, and the transport's own body cap would drop the connection instead of explaining itself.
A step carrying a {{data.*}} token in a run sent without data Nothing would bind it, so the literal token would be sent. The message names the step and the token. The step's credential fields are scanned as well as its request.
A step with a {{data.*}} token in its oauth2 config The token is acquired once, when the plan is resolved, so no iteration exists for a row to reach it. Refused with or without a data set.
A scenario key other than source, collectionId, recursive, data, iterations An unrecognised key used to change nothing and still answer 202 (issue #1503) - refused by name so a typo or a knob a future engine reads is never silently ignored.

A cycle in the collections.parent_id tree terminates the recursive walk rather than hanging it, exactly as the cascade delete in DELETE /collections/:id does.

Five settings bound a scenario (see GET /config). Four are in the limits category, which is where a ceiling that rejects an oversized input lives; maxScenarioStoredSteps is data_retention, because it bounds what a finished run keeps rather than what one may ask for:

Key Default Range Effect
maxScenarioSteps 200 1-10000 Largest plan one run may resolve to. The sequence is composed up front and held in memory, and a load-mode scenario allocates a latency histogram per step, so this bounds memory rather than expressing a preference.
maxScenarioDataRows 1000 1-1000000 Largest inline data array. The app parses the CSV/TSV/JSON/JSONL file and sends the rows - the engine never reads a file from disk - so this bounds the payload that decision costs.
maxScenarioDataBytes 16777216 1024-104857600 Largest inline data array measured in bytes of JSON. The row bound alone does not bound the payload, since one row may hold a megabyte in a single cell.

| maxScenarioStoredSteps | 5000 | 0-1000000 | Per-step results rows one run stores; 0 stores every step. Steps that did not pass are kept first, successes fill the rest, and what was thinned is reported in the run summary. | | maxStepsPerIteration | 0 | 0-1000000 | How many steps one iteration may execute before it is cut off. pm.execution.setNextRequest can send an iteration backwards, so a cycle would otherwise run forever. 0 derives the bound from the plan - ten times its step count, never below 100 - so a straight-through iteration can never trip it. |

The two data bounds are also enforced by the app before the run, against the file it is about to parse, so a set this endpoint would refuse is named while the file can still be changed. The file format those rows come from - which extensions are read, the header row's rules, quoting, encoding, and what a CSV cell's type becomes - is the app's contract and is documented once, in Data-Driven Runs. This endpoint only ever receives rows.

Scenario runs

A scenario run executes every step of the plan, in order, once per iteration. Each step is the POST /execute exchange - pre-request script, send through the environment's cookie jar, test script - so a step behaves exactly as a Send of the same request does, and the two cannot drift apart.

What carries between steps.

  • Variables. The environment, globals and collection scopes are loaded once at run start, mutated in memory by every step's scripts, and written back once, when the run ends (through the same diff that keeps a Send from rewriting a scope no script touched). The collection scope is the collection being run.
  • Cookies. The environment's jar, unchanged - so a step that logs in leaves a session the next step sends.

{{variables}} composition could answer are resolved once, before the first send, not per step. The plan is composed once, so a name that already had a value when the run started is fixed for the whole run: a value a script sets mid-run does not change what a composed {{name}} becomes in a later step's URL, headers or body. This is the price of resolving once, and resolving once is what keeps a collection edited mid-run from changing the sequence underneath it.

A name composition could not answer is a different story (issue #1008). Since #1009 that name keeps its braces instead of resolving to "", so each step resolves it again, immediately before that step's own send, against the scopes as every script up to and including that step's own pre-request script left them - not just through pm.environment.get(...) and a pm.request edit, though a script can still reach it that way too. That is what lets a step early in the plan fetch a token and a later step's Bearer {{token}} carry it with no script of its own. A step's own request holds one search per field, and a plan where every name was already defined at run start pays that and nothing else.

The exceptions are the two reserved namespaces below, which neither pass touches: {{data.*}} and the {{$vu}} / {{$iteration}} identity (issue

994). Composition leaves both alone so the runner can bind them per

iteration, and neither is a name any script scope answers either. A bare name the run's dataColumns names travels the same way (issue #1007): composition defers it exactly as it defers {{data.*}}, and the per-row bind substitutes it before the step's pre-request script ever runs - so neither pass sees it unresolved, and neither can answer it from a same-named environment variable instead of the row.

Scripts additionally read pm.info.iteration (0-based), pm.info.vu (1-based) and pm.info.iterationCount. See scripting.md for which run shapes report which.

A run with data binds one row per iteration. Row i % rows binds to iteration i, and the run's scripts read it as pm.iterationData - get(name) and toObject(), read-only, and undefined for a run sent without data. With iterations absent the row count is the iteration count; with both given the explicit count wins and the index wraps. The rows reach the run's worker and nowhere else: they are not persisted, and the snapshot records dataRowCount only. The full contract is in scripting.md.

{{data.column}} puts the row into the request itself. pm.iterationData is read after a step's request was built, so it cannot change where the request goes; the data.* namespace can. A step whose URL, header or body carries {{data.email}} has it substituted with that iteration's row, per iteration, immediately before the send.

The {{data.column}} spelling is reserved and disjoint from the variable tiers - not a fourth, higher tier. {{data.id}} and {{id}} are different names, so a data set can neither shadow nor be shadowed by a global, collection or environment variable through that spelling, and adding a data file to an existing collection cannot change what its other tokens resolve to. Composition (POST /compose, and the plan resolution that shares it) leaves a data.* token written exactly as it stands for that reason; {{data.}} with no column after it names nothing and follows the ordinary unknown-name rule instead.

A bare column name is a second, different rule and it is a tier (issue

1007). Postman binds a dataset's columns to bare names, so an imported

data-driven collection is written {{username}} rather than {{data.username}}, and while a row is bound that row's own bare column names answer above the active environment - see D18 in variable resolution for the full ladder and the tradeoff it makes against #402's guarantee above. Which bare names a bind owns is not global - it is stated per composition, as the dataColumns field, or filled by the engine itself where it already knows the dataset (a scenario plan's steps, a single-request load run, a send carrying one row). A bare name not in that set resolves as an ordinary variable, exactly as it did before this rule existed; only a name the set names is deferred by composition the way data.* always was, for the run's per-row bind (core::apply_data_template) to join through the identical walk - the same escaping, the same missing-column and null-cell refusals, the same header rules, for either spelling. There is no second, looser substitution path for the bare one.

Where a token participates. Exhaustively, and identically for both spellings: the URL (path and query string alike, so a token in a stored request's params reaches it once they are joined into the URL), every header name and header value, the raw body, both halves of every form field (x-www-form-urlencoded and form-data), and the credential fields of the request's auth - the bearer token, basic auth's username and password, and an api key's name and value. Script text is never interpolated at all (a script reads its row through pm.iterationData, or through pm.variables for a bare name - see scripting.md).

The pass runs over the composed text, not the text as it was authored, so a token that arrived as a variable's value binds like any other: a variable endpoint whose value is /u/{{data.id}} leaves {{data.id}} in the URL after composition - it is rescanned since issue #1009, but the data. namespace is deferred to bind time (issue #1007), so this token survives it - and the data pass then binds it. That is usable, and it is also why the no-data refusal below says "or from the variable value it was written into" - the token it names may not appear anywhere in the request as you wrote it.

{{$vu}} and {{$iteration}} are the second reserved namespace (issue

994), and they bind at the same moment out of the same walk - a field carrying

one of each is one string, so both are split once and joined together. They need no rows behind them: {{$vu}} is the virtual user's own 1-based number in a scenario run and 1 in every other shape (one request repeated is one user's iterations, whatever the concurrency), and {{$iteration}} is that user's 0-based iteration, the submission index on a single-request run, and 0 on a plain POST /execute. A variable named $vu does not answer for the identity, for the reason a variable named data.id does not answer for the column, and {{$vus}} is an ordinary unknown $name that keeps its braces. They bind everywhere a data.* token does, credential fields included (issue #1055): a credential carrying either name defers its build and is bound before apply_auth encodes it, on every run shape rather than only on one carrying rows, because the identity comes from the iteration rather than from a row. An OAuth 2.0 config is the exception and is refused by name - its token is acquired before any iteration exists.

A run binds only what it was given rows for. A POST /runs carrying scenario.data binds per iteration, one carrying the top-level data binds per submission, and a POST /execute carrying a data object binds that one row. A request sent without rows performs no data pass at all, so a {{data.*}} token in it reaches the wire as the literal text {{data.id}} - no substitution, and no warning, since composition leaves the reserved namespace written as it stands by design. The no-data refusal below is the scenario path's alone: a single request has no plan to refuse before it starts, so a raw API or MCP caller putting {{data.*}} into one without rows is asking for the literal braces and gets them.

Credentials bind before they are encoded. A credentials file behind basic auth is the canonical data-driven run, so a step whose credentials carry a {{data.*}} keeps them unresolved in the plan and applies its auth per iteration instead: the row is bound first, and only then does the username and password become one base64 Authorization, or an api key become a percent-encoded query parameter. Every other step still resolves its auth once, when the plan is composed.

OAuth 2.0 is the exception, and it is refused rather than ignored. Its token is acquired once, when the run is planned, so no iteration exists for a row to reach - a {{data.*}} anywhere in an oauth2 config is a 400 at POST /runs naming the token, with or without a data set.

Which columns a collection expects is a separate, declared thing - see dataSchema under Collections and Data-driven runs. Declaring it changes no binding rule; it is what lets the refusal above name the columns, and what the app checks a picked file against before the run.

What a cell renders as. The CSV/TSV path produces only strings, so the first row is the ordinary case; a JSON or JSONL file may carry any type:

Cell Substituted text
String The string, byte for byte - no quoting round trip
Number As JSON writes it: 7, -1.5
Boolean true / false
Object / array Compact JSON: {"a":1}, [1,2]
null Nothing - the bind errors instead, see below

Placement is typed, and that is the point. In a JSON body, a token written inside a string literal produces a string and a token written outside one produces the value's own JSON type:

{ "id": "{{data.id}}", "n": {{data.n}}, "flag": {{data.flag}} }

sends "id":"42", "n":2, "flag":true for a JSON file's {"id":"42","n":2,"flag":true}. Write "n":"{{data.n}}" instead and the number arrives quoted; that is the knob, not a bug.

A value cannot break the document it lands in. For a body whose text is a JSON document - json, jsonrpc, and a graphql body written as the {"query": ...} envelope - a token inside a string literal binds escaped, so a cell carrying ", \ or a newline arrives as its own text inside valid JSON rather than ending the string early. A token outside a string literal is not escaped, which is what keeps typed placement working. Nowhere else is anything escaped: a URL, a header, a form field and a text body take the rendered value byte for byte, and a bare (un-enveloped) GraphQL document is escaped once, later, when the engine wraps it.

An xml body has quoting rules of its own rather than JSON's, so it gets its own encoding - decided per token from where in the document that token sits, because XML has no single escape set the way a JSON string literal does:

Position Encoding
Element text &, <, > escaped as entities
Attribute value the above, plus the quote delimiting that attribute (" or ', whichever the author wrote)
<![CDATA[…]]> verbatim - a ]]> in the value is written ]]]]><![CDATA[>, which reopens the section instead of ending it
A tag or attribute name (<{{data.tag}}>) verbatim - no escape is legal in a name
Inside <!--…--> or <?…?> none - the bind is refused, naming the token

The last row is a refusal rather than an encoding because every candidate is wrong there: a comment is not sent as content at all, a processing instruction is markup addressed to the parser, and a value carrying --> or ?> would end the construct and send a document the author did not write. It errors the step like a missing column does, for every row alike.

The mode decides this, not the content: a text body holding XML still takes the cell byte for byte.

A token naming a column the bound row does not carry errors the step before anything is sent, with a message naming the token, the row index and the columns the row does have. Substituting an empty string would send a request quietly pointing somewhere else, which is the failure this namespace exists to remove.

A cell carrying a CR or LF bound into a header errors the same way. A header line ends at CRLF, so a cell holding ok\r\nX-Admin: true does not put that text in the header - it ends the header and makes the remainder a header of its own, forged by the data file. There is no escape for a line break in a header the way there is for a quote in a JSON body, so the row is refused rather than encoded around, naming the token and the row. The rule covers a header name, a header value, and a credential written into a header line (a bearer token, an api key sent in a header); basic auth's pair and an api key sent in the query are base64- and percent-encoded before they reach the wire, so they bind unchanged. JSON and JSONL rows keep native strings - there is no CSV grammar to have stripped the newline - so this is an ordinary cell, not an exotic one.

The cell is one of three origins the rule covers, and they share one definition of what a header may hold. A {{variable}} substituted into a header is refused by POST /compose with unsendable_header, naming the variable; every other origin - a script, an auth credential, an import, a raw POST /execute payload - is refused before the transfer starts, naming the header. That last gate also covers a NUL (which truncates the line rather than forging one) and a multipart part's field name, filename and content type.

Two headers that bind to one name error the same way. X-{{data.h}} resolving to authorization beside a literal Authorization, or two templated names resolving alike, would leave the request carrying one of the two - so the row is refused instead, naming the header as it is written, the name it produced and the row. Note this is deliberately not composition's duplicate rule, which is last-wins: a duplicate there is two headers the author typed and can see, while this one exists only for the rows that produce it.

A header name a row binds to nothing errors the same way (issue #1095), and is reported ahead of a collision when a row does both - two names that bind to nothing collide on a name neither of them has, which is not what the file's author needs to be told. {{data.header_name}}: acme whose cell is blank would otherwise send the line ": acme", under a name nobody wrote and with a value they did mean, once per iteration. The message is the one every layer that can leave a header nameless shares, with the row in front of it; the column is inside the header as written, where that wording names it. A name a bind merely shortens is not this rule - X-{{data.h}} with a blank cell binds to X-, which is a name a request can carry.

A cell that is present but null errors the same way, naming the token and the row. It is the same failure one type down - the token says the value comes from the file and the file says there is none - and writing "" for it would send {"n": } for a typed placement or a quietly blank field for a quoted one. A column that is legitimately optional belongs in a script, through pm.iterationData (see scripting.md), where null is a value a branch can read.

A run sent without a data set at all whose plan still carries a data.* token is refused outright, before any run row exists: nothing would bind the token, so every iteration would send the literal text {{data.id}}. The 400 names the step and the token, and - when the collection declares a data contract - the columns it declares, so the message says which file to run with rather than only that one is missing:

step 1 (request 'Fetch user', id 'req_a') carries {{data.id}}, but this run has
no 'scenario.data' set. ... (declared columns: id, email)

Starting such a collection as a quick smoke check means running it with a data file - a one-row set is enough - because the run this refuses would not have exercised the endpoint either.

Each step execution writes one results row carrying the design-mode trace plus iteration, stepIndex, stepName, requestId and outcome, and - for a run with data - dataRowIndex, the row that iteration bound. Bodies are capped by maxTraceBodyBytes; the row count is capped by maxScenarioStoredSteps as described above.

A step whose scripts said anything additionally carries a scripts node (issue #724) - the same object and the same four keys POST /execute returns and a design send stores, so a step's assertions read the same inside a run as they do on a single send. It is the only route those results take: a collection run answers 202 long before its scripts have run, so nothing about a step is ever returned live. A step whose scripts said nothing stores no node at all. The results.error summary line ("3 tests failed - ") is unchanged and stays what the outcome is derived from; the node is the list that line summarises.

A step of a collection bound to an OpenAPI document additionally carries a validation node (issue #681) - the same object and the same shape POST /execute returns - saying whether its response matched the schema that document declares. Absent for an unbound collection and for a step that sent nothing. By default the verdict is its own channel and changes no outcome; failOnSchemaError: true on the run makes a schema failure fail a step that passed everything else. The run's rollup is schemaValidation in the report.

Outcomes are passed, failed, skipped and errored:

Outcome Meaning Effect on the iteration
passed The request completed and every assertion held. Continues.
failed A pm.test assertion did not hold. Continues - the request itself completed.
errored The step did not complete: a transport failure, a timeout, or a script that threw. Ends the iteration. The next iteration still runs.
skipped A pre-request script called pm.execution.skipRequest(), so nothing was sent. The row carries the request and no response. Continues with the next step.

A run whose steps failed still reaches completed: the outcome of the work is in the steps, and only the runner itself failing makes the run failed. A stop is honoured between steps, so a stopped run does not finish the iteration it was in.

A step's scripts can redirect the sequence. pm.execution.setNextRequest(name) runs a named request next instead of the one that follows, setNextRequest(null) ends the iteration, and pm.execution.skipRequest() (pre-request scripts only) sends nothing and marks the step skipped. A target that names no step in the run, or one that two steps share, fails the step by name rather than guessing, and maxStepsPerIteration above is what stops a cycle. The full contract, including everywhere the two methods throw, is in scripting.md.

The stored snapshot carries a step manifest, never the composed plan. runs.config_snapshot holds the block as validated - with data replaced by its row count - plus {index, requestId, name, method, url} per step, where url is the stored, uncomposed one. The composed plan carries resolved Authorization headers and, for an apikey auth with in: "query", a live key in the URL; it lives in memory for the run's life and nowhere else.

Controller elements redirect the sequence too (issue #1515). control.if, control.once and control.throughput skip a step the same way pm.execution.skipRequest() does; control.switch jumps to a named member the same way setNextRequest(name) does - one flow-control channel, not two, so the rules above (an unresolved target fails the step by name, a cycle trips maxStepsPerIteration) apply identically. control.loop, on a folder, walks its members a fixed number of times per iteration by looping back to the folder's first member. See elements.md for the kind table and every config shape.

control.transaction reports its own percentiles. A folder carrying one sums every member's own response latency into a named total per pass, and the run's summary gains scenario.transactions[] = { name, count, errors, latency: { min, p50, p90, p95, p99, max } }, omitted for a transaction the run never closed. includeTimers: true (issue #1569) also folds a between-member timer.* wait into that sum - excluded by default, and always excluded for a wait after the folder's own last member, which is outside the transaction's span. The same shape reports on a scenario load run's summary, top-level rather than under scenario - see below.

Scenario load runs

Adding a load mode beside the scenario block runs the same plan as a load test: concurrency virtual users, each walking the sequence on its own, closed-loop on the event loop. The absence of mode is what still means a design-mode collection run, so a payload written before this existed keeps its meaning exactly.

{ "mode": "constant_concurrency", "concurrency": 50, "duration": "60s",
  "scenario": { "source": "collection", "collectionId": "col_1" } }
Field Meaning here
concurrency The number of virtual users - what k6 and JMeter mean by it. Each holds its own position in the plan and its own cookies.
duration Wall-clock length, for constant_concurrency and ramp_up. Virtual users keep starting iterations until it is up.
iterations (top level) mode: "iterations" only: total passes over the plan across all virtual users. Distinct from scenario.iterations, which the design-mode runner reads.
startConcurrency / rampUpDuration ramp_up only, as for a single-request run.

Rejected with a 400 (error.code: "invalid_run_config"), before any run row exists:

Input Rejected because
mode: "constant_rps" with a scenario An open-loop arrival rate over a multi-step sequence is an arrival-rate executor, which Vayu does not implement. Refused rather than silently run closed-loop.
mode: "capacity" with a scenario The search judges one windowed p99 and a sequence has one per step, so which of them the knee is measured against is a question the mode does not answer.
rps / targetRps above zero, on any mode It is what selects the open-loop path regardless of the declared mode.
An unknown mode

maxInFlight is moot and is ignored with a warning: in-flight requests are bounded by the virtual-user count by construction, so concurrency is the only knob.

What differs from a design-mode collection run:

  • The run's type is load, not scenario. It publishes metrics ticks over GET /runs/:runId/live (not step events) and reports RPS and percentiles like any load run.
  • Cookies are per virtual user, empty at the start of each iteration, and the environment jar is untouched. One session shared between 1,000 virtual users is not the thing being measured.
  • The element pipeline runs here too, per virtual user (issue #1495). extract.* and assert.* always run - before the send (step.before) and after the response (step.after). A script.pre / script.post element runs there only when its own config.inline is true, or the run's elements override forces it; unmarked, a step's scripts still stay deferred, keyed per step, exactly as before this issue - see the next bullet. Each virtual user writes through its own overlay, never the run's shared scopes, so a name one user's step 1 writes (an extract.json's target, an inline script's pm.environment.set) is visible only to that same user's later steps, never to another user's concurrent one. pm.execution still throws - a script cannot redirect the sequence under load. A controller element can (issue #1515): control.if, control.once and control.throughput skip a step the way pm.execution.skipRequest() would, counted in the run's summary skipped key rather than the 0 every scenario load run reported before this; control.switch and control.loop jump the plan (issue #1569), resolved the same way a script's own setNextRequest would be and guarded by the same maxStepsPerIteration cycle limit the sequential run uses. control.throughput's perUser: false shares one budget across every virtual user instead of one per user, and control.transaction's includeTimers folds a between-member timer.* wait into its reported sum - both issue #1569 too.
  • A script that did not run inline stays deferred, keyed per step. After the run drains, that step's own post-request script is replayed against the responses that step produced, and the tallies appear on that step's entry in the breakdown as tests (see scenario.steps below). A step that carries no script, whose script ran inline this run, or whose script never got a sampled response, carries no tests object at all rather than a row of zeros.

Sampling is keyed per step for the same reason: the run's max_response_samples budget is split evenly across the steps that carry a deferred script (floored at one apiece), so the last step of a forty-step plan is sampled instead of being crowded out by the first. The whole-run testValidation section still reports the aggregate - it says something failed, and the per-step tests say where. - Data rows are claimed from one shared cursor, one per virtual-user iteration, wrapping when they run out - so two virtual users never start with the same row while unclaimed rows remain, which is what a credentials file is for. Once every row has been claimed the cursor wraps and rows are reused, concurrently: a 10-row file under 50 virtual users, or any duration-mode run past the row count, has several users on one row at a time. Size the file to the concurrency if the rows must stay exclusive. Every step of an iteration binds that iteration's row. scenario.iterations still has no meaning here: the run repeats until its duration is up, and the row count does not bound it.

A {{data.column}} naming a column its bound row does not carry fails that step: nothing is sent, the step's errors count in the breakdown moves, and the run's error list carries an entry with error_type: "data_binding_failed" naming the token, the row and the row's columns. It is never substituted with an empty string. A null cell, two headers binding to one name, and a header name a row binds to nothing fail the same step the same way.

Every retained result carries dataRowIndex on its trace, which is how a failure is attributed to a row when no per-step results rows exist. Absent for a run sent without data. - An errored step ends its iteration and its virtual user starts the next one. It is never stranded. - No per-step results rows are stored - one row per step per iteration per virtual user is what a load run exists not to keep. The report's scenario.steps breakdown is the per-step record instead:

"scenario": {
  "iterations": 480, "iterationsCompleted": 474, "iterationsAbandoned": 6,
  "stepsExecuted": 1422, "errored": 6, "skipped": 12, "virtualUsers": 50,
  "steps": [
    { "index": 0, "name": "Log in", "requestId": "req_a", "method": "POST",
      "executed": 480, "errors": 0, "unresolvedTokens": 0,
      "latency": { "min": 1.2, "p50": 4.0, "p95": 9.1, "p99": 12.4, "max": 30.2 },
      "elements": [
        { "id": "el_1", "kind": "extract.json", "passed": 480, "failed": 0, "skipped": 0 }
      ] },
    { "index": 1, "name": "Get me", "requestId": "req_b", "method": "GET",
      "executed": 474, "errors": 0, "unresolvedTokens": 0,
      "preRequestScript": "skipped",
      "latency": { "min": 0.9, "p50": 3.1, "p95": 7.8, "p99": 10.0, "max": 22.5 },
      "tests": { "sampled": 20, "passed": 20, "failed": 0 } }
  ]
}

One histogram is allocated per plan step at run start, which is the other thing maxScenarioSteps bounds.

tests is the step's deferred validation - a script that did not run inline this run - and is absent for a step whose script ran inline instead (its outcome is in elements), asserted nothing, or drew no sample - "no assertions" and "no failures" are different answers.

elements (issue #1495) is the step's per-element pass/fail/skip tally, { id, kind, passed, failed, skipped } one entry per compiled element that ran at least once this run - absent for a step with no elements or none that ever ran, the same convention tests follows. skipped folds in both a disabled element and a script.* element left deferred to the replay above.

transactions (issue #1515), a sibling of steps rather than a member of it - a control.transaction spans a folder, not one step:

"transactions": [
  { "name": "checkout", "count": 480, "errors": 0,
    "latency": { "min": 8.1, "p50": 14.2, "p90": 22.0, "p95": 26.5,
                 "p99": 33.0, "max": 55.4 } }
]

One entry per declared control.transaction name that closed at least once this run, allocated up front from a scan of the plan - never discovered mid-run - so recording into it, like every other controller here, takes no lock.

unresolvedTokens (issue #1503) counts, per step, how many of its executions sent a {{token}} composition the residual-token pass still could not answer - issue #1495 added a real resolution attempt here (against the executing virtual user's own scope overlay, see elements.md), but the load path's rule is unchanged: never refused, only counted, so a name still unanswered (or a header-name collision the attempt itself produced) goes on the wire regardless. preRequestScript is "skipped" for a step whose script.pre did not run inline this run and absent otherwise - a step whose script.pre did run inline reports its real outcome in elements instead, never both. Neither unresolvedTokens nor preRequestScript ever fails the run by itself; both also feed the top-level warnings array below.

Response:

{
  "runId": "run_1234567890",
  "status": "running"
}

tests, postRequestScript(s) and preRequestScript(s) are refused outright (refuse_legacy_script_fields), the same rule POST /execute, PUT /requests/:id and PUT /collections/:id already applied since issue

1514 - a payload carrying any of the five names (a real value, not null)

is a 400, before the run row exists. The name it points a caller at depends on the shape: requestElements for a single target (this endpoint's own elements means the run-level override, not a script source, and pointing there would send a caller straight into a second refusal), plain elements for a scenario (its steps read theirs off the bound collection's stored elements, which no field on this payload can name more precisely). POST /runs was the one route that still read the old names on a single target - it had nothing else to run a script through until its own element pipeline existed - and issue #1594 closed that gap: requestElements (above) is a single target's own script slot now, the same way a scenario's steps already read theirs off the plan's compiled elements. A caller still sending tests gets the same refusal every other route already gives; MCP's start_load_run never sends it - it folds the agent-facing postRequestScript / tests argument into a script.post entry of requestElements client-side (see mcp.md).

A script.pre element only reaches the wire when it is marked to. The ad-hoc preRequestScript(s) field itself is refused (see above, same as tests) - a pre-request script only ever rides as a stored script.pre element now, on either run shape. Unmarked (config.inline unset, no elements.scripts: "allInline" override), it never runs - there is no pre-request replay to defer it to, unlike script.post - so a request that signs itself in one is sent unsigned. Since issue #1503 this is no longer silent on the scenario shape: a step whose request carries an un-inlined pre-request script reports preRequestScript: "skipped" on its scenario.steps entry, and the run's warnings array carries one line naming how many steps did. The single-target shape reports the same fact differently (see requestElements above): an un-inlined script.pre element's own entry in summary["elements"] carries the "skipped" outcome instead of a separate warning line.

Accepted ranges. The numeric config is range-checked before the run row is created, so a rejected request leaves no pending row behind. A violation is a 400 whose error.code is invalid_run_config rather than the per-status default, and whose message names the offending field and why the bound exists:

Field Accepted Rejected because
success_sample_rate 1-100000 It is a sampling period (keep 1 in N), used as counter % rate. A 0 was a division by zero that killed the daemon mid-run.
response_sample_rate 1-100000 Same modulo, same crash.
max_response_samples 0-1000000 Each retained sample holds a full response body, and the vector is reserved up front; a negative value casts to ~1.8e19.
max_response_sample_bytes 0-1073741824 The whole-run budget for those bodies, held in memory for the run and its retention window. The count cap above bounds the store only for a target whose bodies are small - at 1 MiB each, 1000 samples is ~1 GB. 0 retains no sample that has a body. Defaults to the maxResponseSampleBytes setting.
max_success_results 0-1000000 Each retained record holds a serialised timing breakdown, and the store is reserved up front. 0 means unlimited.
max_slow_results 0-1000000 Same store, same reserve, separate budget. 0 means unlimited.
slow_threshold_ms 0-86400000 ms 0 disables outlier capture; a negative threshold would mark every completion an outlier and fill the slow store with the whole run.
max_sample_body_bytes 0-104857600 A captured body is copied on the completion callback, so the cap bounds hot-path work. 0 keeps headers and metadata and no body. Defaults to the maxSampleBodyBytes setting.
max_sample_bytes 0-1073741824 The whole-run capture budget; every byte under it is held in memory until the run flushes. Defaults to the maxSampleBytes setting.
max_exemplar_results 0-100000 Each retained exemplar holds a captured exchange. 0 means unlimited.
phase_histograms boolean Per-run override for the phaseHistograms setting. false skips the bank entirely, and the run's report carries no timingBreakdown.phases.
save_timing_breakdown boolean Read as a bool inside the run-context constructor, which threw on a string after the row was written - the same stranded-pending failure duration had.
capture_response_bodies boolean Same read, same constructor, same failure.
concurrency 1-10000 Connections are eagerly pre-allocated per worker before any traffic flows, so -1 (a natural "unlimited" guess) allocated until malloc failed.
startConcurrency 1-10000 The ramp is seeded with this many in-flight requests before the first duration check, and it is read as a size_t, so a negative start is ~1.8e19 of them.
maxInFlight 1-1000000 It is a pending-request ceiling read as a size_t, so -1 or 0 removes the backpressure the field exists to provide instead of tightening it, and an open-loop run against a slow target then accumulates in-flight requests for its whole duration. The ceiling is not the concurrency guard: that one bounds an eager per-worker connection pre-allocation, while this bounds a counter that pre-allocates nothing, and the engine's own default - max(targetRps Ɨ 10, 1000) - reaches 500,000 at the load dialog's 50k RPS maximum, so a lower bound would refuse ceilings the engine picks for itself.
timeout 1-86400000 ms A transfer that never times out never completes, leaving the run stuck running and unstoppable.
duration string, positive, optional unit (ms|s|m|h) A JSON number threw out of the run-context constructor after the row was written, stranding it pending forever behind an opaque 500.
stepDuration string, positive, optional unit (ms|s|m|h) capacity only, and read by the same parser duration is - so it is gated by the same rule rather than by a second copy of it.
stream boolean Accepted since streaming under load landed; read through the same parser POST /execute uses. A non-boolean is a 400 rather than a silent buffered run the caller would wait forever for.
maxStreamDurationMs 1000-86400000 ms Streaming only - refused without stream. Defaults to the sseMaxStreamDurationMs setting.
maxStreamEvents 1-10000000 Streaming only - refused without stream. Defaults to the sseMaxStreamEvents setting.
stream_metrics boolean Whether the run feeds the per-completion event histogram. false leaves the report's stream section out entirely rather than zeroing it. Run-config only - there is no engine-wide setting beside it, because the cost is paid only by runs that stream.
sloMs 1-60000 ms capacity only. A non-positive budget has no edge to find, and one past a minute is longer than the transfers any realistic run measures. Matches the app's own clamp on the SLO setting.

An absent field, or an explicit null, is always accepted - every one of them has a default. The ceilings are crash guards, not policy: each client caps itself far lower (the load dialog offers concurrency ≤ 1000; the MCP start_load_run tool has a user-settable cap in Settings).

One field is rejected by presence, not by range: transient. It is POST /execute's no-run-row flag (issue #382) and has no meaning here, because a load or scenario run is the row it creates - the run id is what this endpoint returns, and the live stream, the report and the scenario step store are all keyed by it. Ignoring the flag would leave a caller believing the run left nothing behind while it wrote the largest trace the store holds, so a present transient (true or false) is a 400 with error.code invalid_run_config. An explicit null is absent, as everywhere else here.

The sample rates are additionally clamped to ≥ 1 inside the metrics collector, so the modulo cannot divide by zero even for a caller that bypasses this route.

What a run stores, and for how long. A completed request is recorded in the aggregate counters always; whether its detail survives is decided by three independent budgets:

Budget Filled by Bounded by
Sampled timing traces 1 in success_sample_rate completions, only while save_timing_breakdown is on max_success_results
Slow-request traces any completion at or past slow_threshold_ms, regardless of save_timing_breakdown max_slow_results
Response samples (post-run test scripts) 1 in response_sample_rate completions max_response_samples, and max_response_sample_bytes
Per-status exemplars (captured responses) the first three completions of each distinct status code that no other budget already stored max_exemplar_results

None of these budgets bound the report's timingBreakdown.phases: the per-phase histograms are fed by every completion and hold counts rather than records, so the phase distribution is the whole population no matter how hard the stores above thin. That is what they are for - the avg* fields beside them are computed over the sampled subset.

Two properties are worth relying on. An outlier never consumes a sampling slot: a run whose target degrades does not silently stop sampling ordinary traffic because everything became slow. And each store is a reservoir - past its bound a later record displaces a uniformly chosen incumbent instead of being refused - so what a long run retains describes the whole run rather than its first few seconds, and sampling in the report says how many records that thinning cost.

The exemplar budget is the one exception to the reservoir rule, and deliberately so: an exemplar that gets displaced is not an exemplar, so past its bound a later candidate is refused and counted rather than evicting an incumbent.

It is also the last budget consulted, not the first. A completion that is already an outlier stays charged to the slow budget and a sampled one stays charged to the sampling budget - claiming an exemplar never moves a record out of the store that wanted it, because the first few completions of a status code are often exactly where a run's outliers are. What the exemplar claim decides is whether the response body is captured, which is independent of which budget pays: a completion that is both sampled and a claimed exemplar is stored in the sampling budget and keeps its body.

Response capture. capture_response_bodies (boolean, default true) decides whether the retained samples carry their response headers and body. On by default is only defensible because capture is failure-and-outlier-shaped rather than uniform - errors, slow outliers and the per-status exemplars, never the 1-in-N slice - so a healthy run captures a handful of exchanges. Set it to false and the collector is byte-for-byte what it was before capture existed: no gate is consulted, no exemplar is claimed, nothing is copied, and no rows are written. What is captured is read back with GET /runs/:runId/samples and described in result_bodies.

Shutdown refuses new runs. Once the daemon has begun draining its run workers, POST /runs answers 503 with the message Engine is shutting down rather than accepting a run nothing will ever execute. The window is small - the HTTP server stops before the drain begins - but it is not empty, and a request already in a handler when the drain starts must not be able to spawn a worker past it (see RunManager::shutdown).

Auth pre-flight. When auth.mode is oauth2, the run route resolves the token before creating the run and warms the cache for the workers. An unauthorizable config is rejected up front with 409 (interactive sign-in required) or 400, carrying the /oauth2 error codes, so a bad token never surfaces as a silently-failed run.

Concurrency model. constant_concurrency, ramp_up, and iterations are closed-loop: the engine holds in-flight requests at a target (concurrency, or the ramp curve from startConcurrency to concurrency) - when a request completes, another is issued. Throughput is a result (concurrency Ć· latency), not an input. constant_rps is open-loop: it dispatches at targetRps regardless of how fast responses return.

maxInFlight. A hard cap on concurrent in-flight requests. It applies only to constant_rps (the open-loop rate mode), where it bounds how many requests may be outstanding before the engine drops new ones; default ā‰ˆ max(targetRps Ɨ 10, 1000), accepted range 1-1000000. That range holds the default formula across the whole advertised RPS span (50k RPS → 500,000), which is why it is not the concurrency guard - a ceiling of 10,000 would reject explicitly what the engine already does implicitly. For the closed-loop modes the concurrency target is the in-flight bound, so maxInFlight is ignored there.

Durations. duration and rampUpDuration take a number with an optional unit: ms, s, m, h, matched as a whole suffix ("500ms" is half a second, not 500 minutes). A bare number is seconds - "60" == "60s" - which is also how the MCP duration cap reads the field. Fractions are allowed ("1.5s"), case and spacing are ignored ("30 S"). A value the engine cannot read - an unknown unit, a non-number, a negative - fails the run (status failed, with the offending field named in the daemon log) rather than being silently replaced by the 60s default.

constant_rps is time-bound, and its shortfall is recorded. The generator accrues targetRps Ɨ elapsed and submits the whole requests owed each tick, carrying the fraction - so a rate above 1000 is delivered as asked rather than floored to a multiple of 1000. Requests that come due while in-flight is at maxInFlight are dropped at that instant, not deferred: the run ends at its wall-clock duration, and droppedRequests (in the run summary and per-tick metrics) carries what the rate owed but could not issue. sent + dropped is therefore what targetRps Ɨ duration asked for.

Capacity semantics. capacity is the one mode whose target is not a function of elapsed time. It holds startConcurrency for stepDuration, judges that window's windowed p99 and throughput, and then steps up by 25% (at least +1) while the level stayed inside sloMs. It stops - and names the reason in the report - when p99 exceeds sloMs across two consecutive windows (slo_exceeded; one breaching window re-measures the same level rather than ending the search), when two step-ups buy under 5% more throughput (plateau), when concurrency is reached (cap_reached), when duration runs out (deadline), or when the operator stops the run (stopped).

An omitted duration on a capacity run defaults to 5 minutes, not the 60 seconds every other mode falls back to: this mode walks a level every stepDuration, so a minute is a dozen levels and a search that almost always ends deadline rather than finding anything. Any client enforcing its own duration ceiling has to account for that per-mode default rather than assuming one number - the MCP tool's cap does.

The search steers by the published metric tick - the same numbers GET /runs/:id/live streams - rather than sampling the collector itself, so the controller and the dashboard cannot disagree about a level. Windows in which nothing completed are not judged: their percentiles are the empty-window zeros and reading those as "answered instantly" would climb straight past the limit. capacity is rejected on a scenario run with a 400: the search judges one windowed p99 and a sequence has one per step.

Ramp semantics. ramp_up interpolates linearly from startConcurrency to concurrency over rampUpDuration, then holds concurrency for the rest of duration. A startConcurrency above concurrency is a valid descending ramp. If duration is shorter than rampUpDuration, the run stops partway up (or down) the curve.

Response bodies are capped. A load-run request reads at most maxResponseBodyBytes (Settings → Data & retention, default 32MB) into memory. Every in-flight request holds its own body, so an uncapped one multiplies by concurrency; a response past the cap fails that request rather than being buffered. It is reported like any other transport failure - statusCode: 0 with an errorCode of INTERNAL_ERROR and a message naming maxResponseBodyBytes - and the truncated prefix is kept as the body. Design mode (POST /execute, and each step of a collection run) has its own cap and its own answer: maxDesignResponseBodyBytes, also 32MB, reached by a send that keeps what it read and reports bodyCapped: true on an otherwise ordinary response. It sends one request at a time, so the concurrency argument above does not apply to it - what does is that the body crosses to the app and is held there as well, and that failing a response the user asked to see would be the wrong trade where showing them the first 32MB of it is not. Neither setting reads the other: raising the load cap does not change what a Send reads.

A script's own fetch (pm.sendRequest) is a third read, and it takes the bound of the path it runs on: the design bound in a Send's or a collection step's scripts, the load bound in a load run's deferred tests script, which runs once per sampled response. It refuses at that bound rather than keeping a prefix, on either path, because nothing on the object it hands the script could say the body was cut - see scripting.

Wire method and body. A body is sent with whatever method the request names: a GET carrying one stays a GET on the wire (Elasticsearch-style search bodies work), where it previously went out as a POST. The one combination that cannot be sent is HEAD with a body - curl's HEAD support drops the body - so it is refused rather than silently changed: statusCode: 0, errorCode: INVALID_METHOD, and a message saying so. This holds identically for POST /execute and POST /runs.

Failed requests still report timing. A transfer that fails (timeout, connection refused, capped body) carries whatever curl measured before it failed - wireMs, the phase breakdown, and bytes/throughput counts - instead of reporting zeros. No phase (dnsMs, connectMs, tlsMs, firstByteMs, downloadMs) is ever negative: tlsMs is 0 for plain HTTP and for a reused keep-alive connection rather than the negative value it used to store.

Metrics & Statistics

GET /runs/:runId/metrics

Paginated historical time-series (JSON) for a run's charts. This is the canonical replacement for the legacy GET /stats/:runId?format=json; both call the same run_time_series_response core so they cannot drift. The response is always JSON - any format query param is ignored.

Query parameters: - limit - max records per page (default 5000, invalid/≤0 falls back to 5000, capped at 50000). - offset - skip N records (default 0, negative floored to 0).

Per-tick rows carry the windowed latency percentiles (latency_p50_ms / latency_p95_ms / latency_p99_ms, snake_case) alongside rps/throughput/concurrency/status codes, so the history view can rebuild the percentiles-over-time chart, the response-time-vs-concurrency scatter, and the capacity-breakpoint / saturation stats from stored data.

Response:

{
  "data": [
    {
      "timestamp": 1234567890,
      "elapsed_seconds": 10.5,
      "requests_completed": 1500,
      "requests_failed": 5,
      "current_rps": 150.5,
      "current_concurrency": 100,
      "send_rate": 150.0,
      "throughput": 149.5,
      "backpressure": 0,
      "error_rate": 0.33,
      "dropped_requests": 0,
      "bytes_sent": 48000,
      "bytes_received": 1920000,
      "status_codes": { "200": 1495, "404": 3, "500": 2 },
      "latency_p50_ms": 38.5,
      "latency_p95_ms": 95.1,
      "latency_p99_ms": 12.0
    }
  ],
  "pagination": { "total": 1, "limit": 5000, "offset": 0, "hasMore": false, "returned": 1 }
}

requests_failed is the producer's own error count, written into the tick at write time. It is not derived at read time from error_rate and requests_completed any more - that derivation depended on the order the EAV rows came back in, and reported 0 failed requests for every bucket of every run.

A missing run returns 404 with the message Run not found.

Storage. Each data[] entry is one stored row of metric_ticks - the engine writes the tick object once, at write time. Two things follow:

  • Pagination is tick-aligned: limit/offset count ticks, so pagination.total is the number of ticks (not rows), and a page boundary can no longer return a tick with half its fields zeroed.
  • elapsed_seconds keeps counting across pages, since it is measured from the run's first stored tick.

A run with no ticks returns 200 with an empty data array - only a run that does not exist is a 404.

GET /runs/:runId/monitor

Paginated server vitals scraped during the run - the samples the monitor block collected. Same {data, pagination} envelope and the same limit / offset rules as GET /runs/:runId/metrics, so one pagination reader covers both.

Its own endpoint rather than extra keys on the tick objects: that key set is the /metrics contract, and these samples land on the user's scrape cadence rather than the tick cadence, so they do not line up row for row.

Response:

{
  "data": [
    {
      "timestamp": 1234567890,
      "series": { "node_cpu_seconds_total": 3.75, "process_resident_memory_bytes": 1048576 }
    }
  ],
  "pagination": { "total": 1, "limit": 5000, "offset": 0, "hasMore": false, "returned": 1 }
}

timestamp is wall-clock Unix ms - when the engine scraped, not an elapsed offset - because a tick's elapsed_seconds is measured from the run's first persisted tick while the scrape starts with the run; joining the two series onto one timeline is what the wall clock is for. A series the target did not report in that scrape is absent from its series object rather than zero.

The two fields are read off different clocks, which is why they can disagree. timestamp is wall time, so it moves when the host's clock is adjusted, while every elapsed figure the engine reports (elapsed_seconds here, and a run's duration and rates) is measured on a monotonic clock that an NTP step or a manual clock change cannot move. Over a run that spans an adjustment, the difference between two timestamp values is therefore not exactly the difference between their elapsed_seconds; the elapsed pair is the one that measures how long the run actually took.

A run that configured no monitor returns 200 with an empty data array; only a run that does not exist is a 404. Samples are deleted with the run, like every other child row.

GET /stats/:runId (deprecated)

Prefer GET /runs/:runId/live (above) for live dashboards - it replays a retained in-memory tick topic with no attach race. /stats/:runId is the legacy DB-polling path and is retained wholesale (its SSE mode gets no canonical rename). Its historical ?format=json&limit=&offset= retrieval is a deprecated alias of GET /runs/:runId/metrics (same core); new callers should use that path.

Stream real-time metrics for a load test using Server-Sent Events (SSE).

Response: SSE stream with events:

event: stats
data: {"timestamp":1234567890,"totalRequests":1500,"totalErrors":5,"totalSuccess":1495,"errorRate":0.33,"avgLatencyMs":45.2,"currentRps":150.5,"activeConnections":100,"elapsedSeconds":10.5}

event: complete
data: {"totalRequests":6000,"totalErrors":30,"totalSuccess":5970,"errorRate":0.5,"avgLatencyMs":42.1,"finalRps":100.0,"duration":60.0}

Metrics included: - totalRequests: Total requests completed - totalErrors: Total errors encountered - totalSuccess: Total successful requests - errorRate: Error rate as percentage - avgLatencyMs: Average latency in milliseconds - currentRps: Current requests per second - activeConnections: Active concurrent connections - elapsedSeconds: Elapsed time since test start

This stream is fed from the run's metric_ticks rows; every field above comes from the stored tick except avgLatencyMs, which stays 0 - the per-tick object has never carried mean latency. GET /runs/:runId/live serves it (from the in-memory collector) and is the endpoint to use.

GET /runs/:runId/live

Alias: GET /metrics/live/:runId (deprecated - see Deprecated aliases).

Stream live metrics for a run via Server-Sent Events, replayed from a retained in-memory tick topic. The engine produces one wire-ready metrics tick per liveTickIntervalMs (default 100ms) into a per-run buffer; this endpoint replays that buffer from offset 0 and then tails new ticks until the run finishes, ending with a complete event. Because the topic is retained for liveRetentionMs (default 60000ms) after completion, a client that connects late - even after a sub-second run has already finished - still receives the full series. There is no attach race.

Events:

event: metrics
id: 0
data: {"runId":"...","timestamp":1234567890,"elapsedSeconds":10.5,
       "totalRequests":1500,"totalSuccess":1495,"totalErrors":5,"errorRate":0.33,
       "currentRps":150.5,"sendRate":150.0,"throughput":149.5,
       "activeConnections":100,"backpressure":0,"droppedRequests":0,
       "avgLatencyMs":45.2,"avgQueueWaitMs":0.4,
       "latencyP50Ms":38.5,"latencyP95Ms":95.1,"latencyP99Ms":156.7,
       "bytesSent":48000,"bytesReceived":1920000,
       "requestsSent":1500,"requestsExpected":0,
       "status2xx":1495,"status3xx":0,"status4xx":3,"status5xx":2,
       "statusCodes":{"200":1495,"404":3,"500":2}}

event: complete
data: {"event":"complete","runId":"run_1234567890","status":"Completed"}

status is the run's own terminal status - Completed, Stopped or Failed - and it is what tells a client which of the three happened while the run is ending, rather than after the stored report has been fetched (issue #1415). It is omitted when the stream closes before the run's status has been written, which a client must read as "ask the report", never as success: the field is absent on that frame, not null, and a client older than it saw no status at all.

A scenario run streams step events instead of metrics ticks. One per step execution, on the same ring and the same monotonic id: numbering, so Last-Event-ID resume works identically and a client that reconnects mid-run replays the steps it missed:

event: step
id: 3
data: {"iteration":1,"stepIndex":0,"name":"Log in","outcome":"passed",
       "statusCode":200,"latencyMs":42.7,"dataRowIndex":1,
       "requestId":"req_abc123",
       "tests":{"passed":2,"failed":0},
       "validation":{"checked":true,"valid":true,"matchedStatus":"200",
                     "matchedContentType":"application/json",
                     "failures":[],"failuresTotal":0}}

event: complete
data: {"event":"complete","runId":"run_1234567890","status":"Completed"}

status carries the same three values and the same absent-means-ask rule as on the metrics stream above.

outcome is one of passed, failed, skipped, errored - see Scenario runs. dataRowIndex is present only for a run with data, on the same terms as on the stored row, so a step reads the same live and after a reload. validation is the step's schema verdict (issue #681) in the same shape and on the same terms - absent for a step of an unbound collection and for one that sent nothing, and byte-identical to the node its stored trace carries, so a watched run and a read-back one agree. tests is how many of the step's assertions held (issue #724) - two numbers, because the ring is fixed-size and a script may make hundreds of them; the list itself rides the stored scripts node. It counts the test script's assertions, exactly the ones that node lists, and is absent for a step that asserted none: 0 passed would read as a result. requestId names the stored request the step ran (issue #831) - the same id stamp_step_identity writes onto the stored trace, so a step read live and the same step read back from the report name one request, and a client watching a run can offer the way back to it before the rows are written. One id is constant-size, which is why it rides the frame where the assertion list does not; absent when the plan step names no stored request, because an empty id is not a request. A scenario run publishes no metrics ticks: its work is sequential, so per-tick aggregates would be a rate of one request at a time rather than anything about the sequence.

Its stream opens with one plan frame (issue #1398), published before the first step:

event: plan
id: 0
data: {"stepsPerIteration":4,"iterations":3,"stepsExpected":12}

stepsPerIteration is the plan's length, steps in one pass over it. iterations is how many passes the engine resolved - a data set's row count where the run named no explicit count of its own. stepsExpected is stepsPerIteration * iterations, an upper bound rather than a promise, on the same terms as the load path's requestsExpected above: an errored step ends its iteration, POST /runs/:id/stop ends the run, setNextRequest can walk an iteration in fewer steps than the plan holds, and the maxStepsPerIteration cap can end one that walks in more. It rides the same monotonic id space as the step frames that follow - the same ring, so Last-Event-ID resume works identically - and a client that attaches after the frame has aged out of the retained ring simply never sees it: it should read that as no total rather than compute a fraction of a number nobody sent, and show an indeterminate bar the way it would against an older engine that never sent this frame at all. One frame ahead of the work rather than a number on every tick, because this run publishes no ticks to carry one: the load path's total rides its metrics events precisely because that path has them.

A run with a monitor block also streams monitor events, one per successful scrape, interleaved with its metrics ticks on the same ring and the same monotonic id: numbering - so Last-Event-ID resume replays both kinds in the order they happened:

event: monitor
id: 12
data: {"timestamp":1234567890,
       "series":{"node_cpu_seconds_total":3.75,"process_resident_memory_bytes":1048576}}

The payload is byte-identical to one data[] entry of GET /runs/:runId/monitor, so the live overlay and the history overlay are drawn from the same rows. A scrape that read nothing emits no frame.

Field reference (all keys emitted by MetricsCollector::get_current_stats()):

Field Meaning
totalRequests / totalSuccess / totalErrors Completed counts
errorRate Error percentage
currentRps Instantaneous RPS (delta over the tick window)
sendRate Rate requests are dispatched (open model)
throughput Rate responses are received
activeConnections Current in-flight requests
backpressure Queue depth (requestsSent āˆ’ totalRequests)
droppedRequests Requests discarded at the maxInFlight cap (never sent)
avgLatencyMs Mean perceived latency
avgQueueWaitMs Mean time queued inside the generator before the wire
latencyP50Ms / latencyP95Ms / latencyP99Ms Live windowed percentiles - a rolling per-tick window sampled from a phaser-based hdr_interval_recorder, so the chart tracks recent load instead of flattening toward the all-time distribution (the final report still uses the cumulative histogram)
bytesSent / bytesReceived Cumulative wire bytes
requestsSent / requestsExpected Progress for bounded modes (drives ETA)
status2xx–status5xx Per-class counts
statusCodes Full per-code distribution map

Each metrics event carries an id: equal to its zero-based offset. The browser's built-in EventSource retry automatically replays this id as Last-Event-ID on its own intra-connection retries (no application code needed), and the stream resumes from Last-Event-ID + 1.

Replay window. The in-memory tick topic is a bounded ring, so a long run's memory does not grow with its duration. Its span is liveReplayWindowMs (default 300000, i.e. 5 minutes) and the tick count is derived from that window and the configured cadence - liveReplayWindowMs / liveTickIntervalMs, 3000 ticks at both defaults. The bound is a duration rather than a fixed count because the cadence is itself configurable: one tick count would mean a 30-second window at liveTickIntervalMs=10 and a 50-minute one at 1000. liveReplayWindowMs = 0 means the full run (no time limit). Whatever the pair, the ring is capped at liveMaxRetainedTicks (default 50,000, ~50 MB), so a fast cadence reaches that ceiling before a long window does. Raising it is cheap at stock settings - the window, not the ceiling, is what sizes the ring.

Ids keep counting past an eviction, so they stay monotonic; a Last-Event-ID older than the retained window resumes from the oldest retained tick rather than replaying from 0, which means a client that was disconnected for longer than the window sees a gap, not a duplicate flood.

The window also bounds the replay-from-0 path below, which is the one the bundled app actually exercises: a dashboard attaching (or re-attaching) mid-run rebuilds its chart from the retained ring. That is why liveReplayWindowMs is the same setting as the app's live-chart window rather than a second one to keep aligned - the app's Settings → Live Dashboard → Chart window picker reads and writes this entry (GET/POST /config), so the span the engine retains and the span the chart displays cannot disagree. Editing it here and editing it there are the same action.

The final metrics event is emitted only once the run's worker has actually settled. On POST /runs/:runId/stop the engine keeps ticking while in-flight requests are cancelled and recorded, so the last live numbers agree with the stored report rather than freezing at the moment of the stop request.

Application-level reconnect: clients that close the EventSource themselves (e.g. after observing readyState === CLOSED) should NOT open a new connection and rely on Last-Event-ID - EventSource does not expose a header-setting API, so a fresh connect would request from=0 and replay the entire retained topic, duplicating ticks already shown. The canonical recovery is to converge on the stored report via GET /runs/:runId/report (the same path used at normal run end). This is the pattern the bundled app uses.

Responses: - 200 - SSE stream (active run, or finished run still within the retention window). - 404 - run not found or evicted past liveRetentionMs; the body hints Use /runs/:runId/report for the stored report. Clients should fall back to the stored report in this case.

Tuning: liveTickIntervalMs (live tick cadence, 10–1000ms), liveReplayWindowMs (retained replay span and the dashboard's chart window, 0–3600000ms; 0 = full run), liveMaxRetainedTicks (the tick ceiling for that window on both sides, 1000–500000) and liveRetentionMs (post-completion retention, 0–600000ms; 0 disables retention) are configurable via POST /config.

GET /runs/:runId/events

Relay a streaming run's events via Server-Sent Events (issue #573). Started by POST /execute with "stream": true, which returns this URL as eventsUrl.

The endpoint replays the run's retained events, then tails until the stream terminates, and closes with a complete event naming the termination reason. Like the live-metrics topic, the ring is retained for liveRetentionMs after the stream ends, so a client that connects late - even after a short stream has already finished - still receives the whole series.

Events:

event: open
id: 0
data: {"statusCode":200,"statusText":"OK","headers":{"content-type":"text/event-stream"}}

event: message
id: 1
data: {"event":"token","data":"Hello","sourceId":"42","receivedAt":1234567890}

event: complete
data: {"runId":"run_1234567890","reason":"completed","totalEvents":128}

  • open is published once, as soon as the response's header block arrives, so even a late consumer learns what the stream connected to.
  • message carries one upstream event: its event name (message when the origin sent none), its data (multiple data: lines joined with \n), the origin's own id: as sourceId, and receivedAt. An event larger than sseMaxEventBytes is stored as a prefix and says so in band, with dataTruncated: true and dataBytes (the size as sent) - never silently cut.
  • complete's reason is one of completed, stopped, maxStreamEvents, maxStreamDurationMs, idleTimeout, error; see POST /execute.

sourceId is not the resume point. id: on the wire is this relay's own frame offset, which is what ?lastEventId= / Last-Event-ID takes; sourceId is what the origin would want back. Conflating them would make one of the two resumes silently wrong.

Resume picks up at the frame after the one named, so a dropped consumer re-renders nothing. The header wins over the query parameter when both are present (EventSource cannot set a header on a fresh connection, so a client owning its retry uses the parameter). Unlike the inbox's capture ids, frame ids start at 0, so lastEventId=0 means "I saw frame 0" rather than "from the start"; absence is what means the start. A present-but-unreadable value is a 400 (invalid_last_event_id) rather than a silent replay from 0. A resume point older than the retained window is fast-forwarded to the oldest retained frame rather than looping on ids that will never come back.

One consumer at a time. Each stream parks a cpp-httplib pool thread for its whole life, so a second concurrent watcher is a 409 (run_events_in_use) - but a claim whose holder has stopped writing for two keep-alive intervals is taken over instead of refused, since EventSource treats a 409 as fatal and a reconnect racing the previous socket's death would otherwise kill the stream for good.

Responses: - 200 - SSE stream (live stream, or finished one still within the retention window). - 400 - unreadable lastEventId / Last-Event-ID. - 409 - already being streamed. - 404 - no stream for this run, or it expired past liveRetentionMs; the body hints GET /runs/:runId/report, whose trace carries the stored events node.

Runs

GET /runs

List test runs (design mode, load tests and collection runs), newest first (start_time DESC - the only order the UI uses). Rows carry a compact summary rather than the full configSnapshot, so the polled history sidebar stays cheap as history grows.

Each row's summary is derived from that run's config_snapshot, and the derivation is cached per run id (issue #1150): a snapshot is written once, when the run is created, so a repeated poll re-serves what the previous one built instead of re-parsing one snapshot per row every tick - 50 at the default page size, up to 500 at the cap. The cache is bounded and holds nothing a request cannot rebuild, so it changes no answer - a run deleted between polls is gone from the list because the query no longer returns it, not because anything was invalidated. This route also logs at debug, not info: it is the one endpoint a visible client polls, and an info line here reached the console of every engine started at the default app verbosity, every five seconds.

Query parameters (passing any of them opts into the paginated envelope): - limit - page size (default 50, invalid/≤0 falls back to 50, capped at 500). - offset - rows to skip (default 0, negative floored to 0). - type - design | load | scenario (an unrecognised value is ignored, not an error). - status - a RunStatus string (pending | running | completed | failed | stopped; unrecognised ignored). - requestId - exact match on the run's linked request. - collectionId - exact match on a collection run's stored scenario.collectionId, read out of the snapshot as JSON. This is the deliberate opposite of q below: it matches the field, never the text around it, so a design run whose URL happens to contain the id does not come back. Only a collection run records the path, so type: "design" and type: "load" runs can never match; an id nothing has run is an empty page, not an error. A collection's most recent run is ?collectionId=<id>&limit=1, since the list is already start_time DESC. - q - case-insensitive substring over the stored config_snapshot text (SQL LIKE). It searches the raw snapshot, so it may over-match JSON keys or structure - acceptable for a search box. - baseline - true lists only runs pinned as baselines, false only unpinned ones (any other value is ignored, like an unrecognised type). Leaving it out lists both, so omit it rather than passing false to mean "either". A request's current baseline is ?baseline=true&requestId=<id>&limit=1, since the list is already start_time DESC - the lookup both the history view's vs-baseline strip and the MCP compare_runs tool make.

Every parameter composes with every other; each one left out is a wildcard.

summary carries exactly these ten keys: url, method, mode, duration, concurrency, comment, followRedirects, maxRedirects, requestName, and httpVersion. The first nine are each omitted when absent from the snapshot (a malformed snapshot yields an empty summary, never a 500); httpVersion alone is always present. requestName is the request's name as the client sent it when the run started - never re-read from the requests table, the same trust model url and method already have here - so a request renamed or deleted since does not change what a past run's row says it invoked, and a run whose client omitted the field (or one recorded before it existed) has no key at all rather than a fabricated name. It reaches config_snapshot the same way url/method do: whatever the client's POST /runs or POST /execute body carried; the renderer sends it from execIdentity (execute-mapping.ts), the same field POST /compose's requestId path already stamps for the script sandbox's pm.info.requestName. Every run since issue #1488 adds an eleventh, acceptEncoding: true when the run negotiated a compressed response (negotiateCompression for a collection run, loadNegotiateCompression for a load run - see Default request headers), false when it did not, and omitted, not defaulted, for a run recorded before that issue - the baseline comparison (below) is the one reader that treats an absent key as false, matching what every such run actually sent. A raw POST /runs body of "httpVersion": null (erased before execution, so it behaves exactly like an absent key - see POST /runs) lands in the stored snapshot verbatim, and a run predating this field has no key at all; neither case recorded a protocol, so both normalize to the literal string "auto" rather than being omitted, which would misrepresent "nothing was recorded" as "we lost it". Do not read "auto" on an old run as the protocol it used: a load run stored before 0.11.0 hardcoded CURL_HTTP_VERSION_2TLS, and every run before it went out as HTTP/1.1 regardless, because nghttp2 was not linked. The full snapshot stays available on GET /runs/:runId.

A collection run (type: "scenario") carries none of the first eight: its work is a sequence, so there is no single url, method or mode to report. Its row instead carries a twelfth key, scenario, present on scenario runs only:

"scenario": {
  "collectionId": "col_1234567890",
  "iterations": 3,
  "recursive": true,
  "stepCount": 12
}

stepCount is the length of the snapshot's step manifest, not the manifest itself - a row that shipped every step's name, method and URL would undo the reason summary exists. The manifest stays on GET /runs/:runId. Each of the four keys is omitted when the stored snapshot has no such key.

hasWarnings (issue #1527) is summary's thirteenth key, true on a run whose stored summary.warnings array (issue #1503) is non-empty and omitted otherwise - a run still in progress, one whose terminal write failed, or one that finished with nothing to say. Unlike the other keys above, it is not read out of config_snapshot and not part of the cached compact summary: it is a completion-time fact, read fresh off the row on every poll, so a history surface that polled a still-running run sees the glyph appear on the next poll after it finishes, never stuck at the answer its first poll cached. The full warnings array itself stays on GET /runs/:runId's summary.

baseline is on every row, true only for a run pinned through PUT /runs/:runId/baseline. It is also on GET /runs/:runId, so a client that opened a run directly can draw the pin without listing.

resultSummary is what a design run's row says about the exchange: statusCode and latencyMs, and nothing else. A design run is one request and one response, so its outcome fits on the row and a page of them costs one extra query; a load or collection run's results are many and unbounded, so its row carries no resultSummary at all and its result rows are never read to build one - the same split GET /runs/:runId draws when it attaches result. The two numbers rather than that whole result: it carries the exchange's trace, request and response bodies included, which is a per-row cost a list cannot take. A design run with no stored result - still running, or one whose result write failed - omits the key rather than reporting statusCode: 0, which is the wire's own way of saying the request never reached a server.

Response (envelope):

{
  "data": [
    {
      "id": "run_1234567890",
      "requestId": "req_1234567890",
      "environmentId": null,
      "type": "load",
      "status": "completed",
      "startTime": 1234567890,
      "endTime": 1234567891,
      "baseline": true,
      "summary": {
        "url": "https://api.example.com/users",
        "method": "GET",
        "mode": "constant_rps",
        "duration": "60s",
        "concurrency": 100,
        "comment": "nightly",
        "followRedirects": true,
        "maxRedirects": 10,
        "httpVersion": "auto"
      }
    },
    {
      "id": "run_1234567891",
      "requestId": "req_1234567890",
      "environmentId": null,
      "type": "design",
      "status": "completed",
      "startTime": 1234567892,
      "endTime": 1234567893,
      "baseline": false,
      "summary": { "url": "https://api.example.com/users", "method": "GET", "httpVersion": "auto" },
      "resultSummary": { "statusCode": 200, "latencyMs": 34.2 }
    }
  ],
  "pagination": { "total": 812, "limit": 50, "offset": 0, "hasMore": true, "returned": 50 }
}

Legacy no-param behavior (deprecated, removed next minor). A request with no query params at all returns today's bare array of full-configSnapshot rows unchanged, so external scripts keep working:

[
  {
    "id": "run_1234567890",
    "requestId": "req_1234567890",
    "environmentId": "env_1234567890",
    "type": "design",
    "status": "completed",
    "configSnapshot": "{}",
    "startTime": 1234567890,
    "endTime": 1234567891
  }
]
This legacy branch is a temporary alias (like those in Deprecated aliases) and is removed at the next minor release; new callers should always pass pagination params and read the {data, pagination} envelope.

GET /runs/:runId

Alias: GET /run/:runId (deprecated - see Deprecated aliases).

Get details for a specific run.

Response: The run object shown in GET /runs (id, requestId, environmentId, type, status, configSnapshot, startTime, endTime, baseline).

For a design run that has at least one stored result, the response also carries a result object with that run's single exchange - the only other place it appears is GET /runs/:runId/report, whose results array and metadata.configuration are load-test concepts and are absent for a design run. result is design-only by construction: it serves the first stored row on the assumption that there is exactly one, and a scenario run has one per step - its steps are read from the report's results array instead.

{
  "id": "run_1234567890",
  "requestId": "req_1234567890",
  "environmentId": null,
  "type": "design",
  "status": "completed",
  "configSnapshot": { "...": "the raw run payload" },
  "startTime": 1234567890,
  "endTime": 1234567891,
  "result": {
    "timestamp": 1234567891,
    "statusCode": 200,
    "statusText": "OK",
    "latencyMs": 42.1,
    "error": "optional, only when the request failed",
    "trace": { "request": { "...": "..." }, "response": { "...": "..." } }
  }
}

If the engine truncated a body for storage (over maxTraceBodyBytes), the affected trace.request and/or trace.response object carries "bodyTruncated": true and "bodyBytes": <original length>; its body then holds only the first maxTraceBodyBytes of the original. Absent when the body fit. Clients surface this as a "body truncated for storage" notice and re-send to fetch the whole body.

configSnapshot.body is capped the same way, independently of the trace (issue #1486): content over maxTraceBodyBytes is cut to that length and the body object gains the same bodyTruncated / bodyBytes pair. A run can be truncated on one side and not the other, since each is written by its own call into sanitize_config_snapshot.

A load or collection run's configSnapshot also carries defaultHeaders (issue #1488), the run's resolved decision at start about what the engine would add to a request nobody wrote it into - never re-read for the send itself (see Default request headers), kept only so a later run's report can say whether the two measured under the same conditions:

"defaultHeaders": { "userAgent": "Vayu/0.26.0", "requestId": false, "acceptEncoding": true }

Absent on a design run (it carries no throughput to compare) and on any run recorded before this issue.

POST /runs/:runId/stop

Alias: POST /run/:runId/stop (deprecated - see Deprecated aliases).

Stop a running load test. The engine signals the run, waits up to 5s for its worker to settle, and answers with a summary of what the run actually did.

Stop discards, it does not drain. The queued backlog is thrown away rather than sent, and transfers already in flight are cancelled (removed from curl and completed as an INTERNAL_ERROR "Request cancelled"), so the target stops receiving traffic immediately and the stop's latency does not belong to the upstream. A cancelled request was submitted, so it is counted: it lands in the run's errors, which is what keeps requests_sent and the recorded total equal.

A run that ends naturally still lets its in-flight requests finish, but only up to its own timeout plus a 2s grace; anything still outstanding then is cancelled the same way. Without that bound an upstream that never answers would hold the run in running indefinitely.

Response (active run):

{
  "status": "stopped",
  "runId": "run_1234567890",
  "summary": {
    "totalRequests": 1500,
    "errors": 5,
    "errorRate": 0.33,
    "avgLatencyMs": 38.9
  }
}

avgLatencyMs is the same average the final report and the live ticks show: the latency sum over the requests that contributed to it (successes). It is not divided by totalRequests, which would report a lower figure here than everywhere else for the same run.

A run that is already finished answers {"status": "<status>", "runId": ..., "message": "Run already <status>"}; one that is not in memory answers {"status": "stopped", "runId": ..., "message": "Run was not active"}.

A streaming design run (POST /execute with "stream": true) is stopped here too: the endpoint asks its consumer worker to end the transfer, waits up to the same 5s, and answers {"runId": ..., "status": "stopped", "message": "Stream stopped", "totalEvents": N}. The worker owns the terminal write, so the run reaches stopped with its trace and its true event count together; the stream's complete event carries "reason": "stopped". A stream that had already terminated keeps the reason it recorded rather than being rewritten as a user stop.

GET /runs/:runId/report

Alias: GET /run/:runId/report (deprecated - see Deprecated aliases).

Get the final report for a completed run. The response is a nested object; conditional sections appear only when relevant (e.g. rateControl only for constant_rps, testValidation only when a test script ran, thresholdValidation only when the run declared budgets, capacity only for a capacity run, auth only when the run's OAuth 2.0 credential could be refreshed mid-run, coverage only for a run measured against a bound spec, schemaValidation only for a run that checked at least one response against one).

The whole-run aggregates come from the run's stored summary (written once when the run reaches a terminal status - see db-schema.md), combined with the sampled results rows for the timing breakdown and the results[] array. A run with no summary never reached a terminal status - the engine died mid-run - and its report is built from those sampled results alone rather than erroring. The response shape is the same either way.

metadata.openapi appears only for a run whose collection was bound to a spec when the run was planned (issue #637). It is echoed from the run's snapshot rather than re-read from the collection: a report has to say what the run was measured against, and the binding is free to have moved since. An unbound run carries no openapi key at all - absent rather than an empty object, so "not measured against a spec" has one spelling.

The binding is resolved by walking from the run's collection up to the root and taking the nearest bound ancestor, the same walk a single request's design-mode validation uses (issue #716). An import binds the root and files every request under tag sub-collections, so running one tag folder is measured against the document its root binds. When the binding came from an ancestor rather than from the collection the run named, the node carries "inherited": true - absent otherwise, like every other finding here. The run still enumerates only its own subtree, so the coverage below is partial by construction: most of the contract's operations are honestly uncovered, and that flag is what lets a reader tell scoped-run truth from a collection that has stopped calling its API.

coverage says which of that spec's operations the run exercised and which of their declared responses it saw (issue #629). It is present only for a scenario run - design or load - of a collection bound to a document that carries an operation index, and absent, never zeros, for every other run: an unbound collection, a single-request run, and a document stored before the index existed all report no block rather than a contract covered zero of.

Field Meaning
operationsTotal / operationsCovered Declared operations, and how many got at least one request
declaredResponsesTotal / declaredResponsesHit / declaredResponseCoveragePct Declared status patterns, and how many the run produced
undeclaredStatusesSeen Distinct (operation, status) pairs the document declares nothing for
operations[] Per operation: sent, statusesSeen, declaredHit, declaredMissed, undeclaredSeen, and transportErrors / otherStatusResponses / statusesTruncated when non-zero
operations[].statusesTruncated Distinct statuses the operation answered with that appear in neither list - the two are capped at 50 each and undeclaredSeen repeats codes from statusesSeen, so this is not the entries the two dropped between them
transportErrors / undeclaredOperationRequests Whole-run findings, present only when they happened

Rows come back uncovered first - an operation nothing exercised is the finding - and in document order within each group, so two runs of one contract print the same way. A status hits the most specific declared pattern that covers it (exact, then a 2XX range, then default), so an operation declaring both 200 and 2XX that only answered 200 reports 2XX as missed: those are two distinct promises. A transport error counts as a request sent and as no response seen; status 0 is never listed as a status the server sent.

Every number here is exact, not sampled. The engine counts each send and each response as it happens, through a tally that is not the bounded results[] store - so a load run whose rows were thinned to a reservoir still reports every operation it touched. Coverage is computed against the document metadata.openapi names, at the moment the plan resolved, and stored with the run; a binding that has since synced to a newer spec cannot rewrite it.

operationsCovered, operationsTotal and declaredResponseCoveragePct are deliberately plain numbers, shaped like thresholdValidation's counts, so a headless CI gate can threshold on them without the block being reshaped. Nothing thresholds on them today.

schemaValidation says whether the responses a run checked matched the schemas that same document declares (issues #682, #681). It sits beside coverage and is computed against the same document, but on different evidence, and the difference is the block's most important field: coverage counts every send, while this walks whatever the run's mode gave it - the bounded reservoir of responses a load run stored, or every step a collection run executed.

Field Meaning
sampled Responses this pass walked - the denominator for everything else
checked Of those, the ones a declared schema could speak about
valid / failed The partition of checked. valid + failed == checked, always
unevaluated Checked responses whose schema carried a keyword the draft-07 validator could not evaluate
uncheckedReasons Reason code -> count, accounting for every one of sampled - checked
unevaluatedKeywords[] {keyword, count}, so what went unread is named and not only counted
failures[] Bounded examples: {step?, status, path, message}
failuresTotal Every failure found, the cap included - so "3 shown of 90" stays readable
exact true when sampled is the whole population rather than a reservoir - written by a collection run, absent for a load run
failOnSchemaError Whether a schema failure was allowed to fail its step. Collection runs only

The reason codes are the ones POST /execute's validation node uses, unchanged.

Under load these numbers are sampled, and nothing here pretends otherwise. Validation is deferred to run end because a load run refills concurrency on every completion, so a schema walk on that path would cost throughput for the whole run - and would do so invisibly. failed: 0 therefore means "no sampled response failed". sampled is written so a reader always has the denominator; the app renders it beside the tallies for the same reason.

A collection run writes the same block with exact: true, because it sends one request at a time and checks every step - there is no hot path to keep off, so sampled there is the run. The flag exists so a reader is never left to infer the denominator from the run's mode: the same five numbers are a wider claim in one than the other, and only the block knows which. A report written before the flag existed was sampled, which is why absent reads as sampled.

A step's responses are kept when a deferred pass will read them - it carries a script, or it is bound to an operation and the document carries schemas - and the run's sample budget is split evenly across those steps. What was displaced is reported as sampling.response_samples_dropped.

Absent, never zeros, for every run that checked nothing: an unbound collection, a single-request run, a document carrying no response schemas, and a run whose reservoirs held nothing. A run whose responses were never checked did not pass a contract.

Per-step verdicts in a collection run

Each step of a collection run carries its own verdict on its stored trace as a validation node - the same object and the same shape POST /execute returns - and the live step SSE frame carries it too, so a run being watched and the same run read back cannot disagree (issue #681). A step that sent nothing carries no validation at all: there was no response to judge.

failOnSchemaError is a top-level boolean on POST /runs, default false, type-checked before the run row is created. Left off, a schema failure does not change any step's outcome: it is its own verdict channel, and a response the document does not describe is not a failed assertion. Set, a step that passed everything else and whose response did not match is classified failed, with the first problem in its results.error; a step already failing keeps the error that named it.

Response:

{
  "metadata": {
    "runId": "run_1234567890",
    "runType": "load",
    "status": "completed",
    "startTime": 1234567890,
    "endTime": 1234567950,
    "requestUrl": "https://api.example.com/users",
    "requestMethod": "GET",
    "configuration": {
      "mode": "constant_rps",
      "duration": "60s",
      "concurrency": 100,
      "followRedirects": true,
      "maxRedirects": 10,
      "httpVersion": "auto",
      "dataRowCount": 2,
      "acceptEncoding": true
    },
    "openapi": {
      "specId": "spec_3f2b1c9a-...",
      "specHash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
    }
  },
  "summary": {
    "totalRequests": 6000,
    "successfulRequests": 5970,
    "failedRequests": 30,
    "errorRate": 0.5,
    "totalDurationSeconds": 60.0,
    "avgRps": 100.0,
    "testDuration": 60.0,
    "sendRate": 100.0,
    "throughput": 99.5,
    "setupOverhead": 0.12,
    "peakConcurrency": 100,
    "droppedRequests": 0,
    "avgQueueWaitMs": 0.4,
    "bytesSent": 192000,
    "bytesReceived": 7680000,
    "throughputBytesPerSec": 128000,
    "httpVersionDowngraded": 0
  },
  "latency": {
    "min": 12.3, "max": 1250.5, "avg": 42.1, "median": 38.5,
    "p50": 38.5, "p75": 45.2, "p90": 78.3, "p95": 95.1, "p99": 156.7, "p999": 450.2
  },
  "statusCodes": { "200": 5970, "500": 30 },
  "rateControl": { "targetRps": 100, "actualRps": 99.5, "achievement": 99.5 },
  "errors": {
    "total": 30,
    "withDetails": 30,
    "types": { "timeout": 20, "connection_failed": 10 },
    "byStatusCode": { "500": 30 }
  },
  "timingBreakdown": {
    "avgDnsMs": 5.2, "avgConnectMs": 12.3, "avgTlsMs": 45.1,
    "avgFirstByteMs": 180.2, "avgDownloadMs": 2.7,
    "phases": {
      "dns":       { "p50": 0.1, "p95": 0.2, "p99": 1.4,  "max": 12.0, "count": 6000 },
      "connect":   { "p50": 0.3, "p95": 0.9, "p99": 8.2,  "max": 40.1, "count": 6000 },
      "tls":       { "p50": 0.0, "p95": 0.0, "p99": 22.0, "max": 61.0, "count": 6000 },
      "firstByte": { "p50": 2.9, "p95": 4.0, "p99": 6.1,  "max": 30.0, "count": 6000 },
      "download":  { "p50": 0.1, "p95": 0.3, "p99": 0.9,  "max": 4.2,  "count": 6000 }
    }
  },
  "slowRequests": { "count": 12, "thresholdMs": 1000, "percentage": 0.2 },
  "warnings": [
    { "code": "unresolved_tokens",
      "message": "12 requests sent with unresolved variables: token",
      "count": 12, "names": ["token"] },
    { "code": "pre_request_script_skipped",
      "message": "1 step carries a pre-request script that does not run under load",
      "steps": 1 }
  ],
  "stream": { "...": "streaming runs only - see below" },
  "sampling": {
    "errorsDropped": 0, "successTracesDropped": 29000,
    "slowTracesDropped": 0, "responseSamplesDropped": 998000,
    "exemplarsDropped": 0, "sampleBodiesDropped": 12,
    "responseBodiesCaptured": 23, "responseSampleBudgetSpent": false
  },
  "monitor": {
    "samples": 60, "failures": 0,
    "series": {
      "node_cpu_seconds_total": { "min": 1.2, "max": 3.9, "avg": 2.6, "count": 60 }
    }
  },
  "testValidation": { "samplesTested": 500, "testsPassed": 498, "testsFailed": 2, "successRate": 99.6 },
  "thresholdValidation": {
    "checks": [
      { "metric": "latencyP99Ms", "limit": 50, "actual": 47.2, "passed": true, "evaluated": true }
    ],
    "passed": 1, "failed": 0, "verdict": "passed"
  },
  "customMetrics": {
    "ttfb2": { "type": "trend", "count": 42, "p50": 10.0, "p95": 40.0, "p99": 47.0, "max": 50.0 },
    "bytesOut": { "type": "counter", "count": 4, "value": 4096.0 },
    "cacheHit": { "type": "rate", "count": 10, "value": 30.0 }
  },
  "auth": { "refreshes": [ { "atSeconds": 3620.4 } ], "refreshFailures": 0 },
  "coverage": {
    "operationsTotal": 18, "operationsCovered": 14,
    "declaredResponsesTotal": 41, "declaredResponsesHit": 29,
    "declaredResponseCoveragePct": 70.7, "undeclaredStatusesSeen": 1,
    "operations": [
      { "operationId": "deletePet", "method": "DELETE", "path": "/pets/{petId}",
        "sent": 0, "statusesSeen": [], "declaredHit": [], "declaredMissed": ["204"],
        "undeclaredSeen": [] }
    ]
  },
  "schemaValidation": {
    "sampled": 40, "checked": 36, "valid": 30, "failed": 6, "unevaluated": 0,
    "uncheckedReasons": { "body_not_json": 4 },
    "failures": [ { "step": "get pet", "status": 200, "path": "/id", "message": "Value type not permitted by 'type' constraint." } ],
    "failuresTotal": 6
  },
  "lifecycle": {
    "setup": [ { "id": "el_setup1", "kind": "script.setup", "outcome": "ok" } ],
    "teardown": [ { "id": "el_teardown1", "kind": "script.teardown", "outcome": "error", "message": "Error: boom" } ]
  },
  "elements": [
    { "id": "el_1", "kind": "assert.status", "passed": 9, "failed": 1, "skipped": 0 },
    { "id": "el_2", "kind": "extract.json", "passed": 10, "failed": 0, "skipped": 0 }
  ],
  "results": [ { "id": 41, "...": "sampled request/response outcomes" } ]
}

elements (issues #1594, #1641) is a single-request run's own requestElements outcomes - extract.* / assert.* / timer.*, tallied across every submission as {id, kind, passed, failed, skipped}. Absent, not [], for a run that declared no requestElements, one whose elements never ran, and every scenario run - a scenario's own per-step tallies live under scenario.steps[].elements instead (see The scenario block and Scenario load runs), and the two never coexist on one report.

customMetrics (issue #1500) is this run's metric.record / pm.metrics values, by name - absent, not {}, for a run that recorded none. A "trend" entry carries count/p50/p95/p99/ max; a "counter" or "rate" entry carries count and value (a running total, or a 0-100 percentage). The same shape rides every GET /runs/:runId/metrics tick, and custom.<name>.<stat> reads it for a threshold.

timingBreakdown holds two independently-present halves. The avg* fields are means over the run's retained trace sample - the 1-in-success_sample_rate completions stored while save_timing_breakdown is on, plus any slow-request outliers - so they are absent for a run that stored no traces. phases comes from five HdrHistograms fed by every successful completion, so it is present for exactly such a run, and absent only when phaseHistograms was off, nothing succeeded, or the run predates the bank. Read each half by its own key: the object's presence proves neither, and the two are drawn from different populations, so a phases.tls.p50 is not comparable to an avgTlsMs.

phases is what answers "was the latency the server or the connection path". A tls.p50 of 0 beside a large tls.p99 is a run re-handshaking under load - most requests reused a connection, a minority did not - which the average over both flattens into a number that looks merely mediocre. count is the number of completions behind each distribution and is identical across the five.

warnings (issue #1503) is what the run did not do, even though it finished and its other numbers may look fine: a request sent with a {{token}} composition never resolved (no mode runs a residual pass under load, so a value a pre-request script would have set goes on the wire literally), or a step whose pre-request script this mode never executes at all. Neither refuses the run - a literal {{ can be deliberate in a body - they are counted, not fixed. Each entry carries code and a human-readable message; unresolved_tokens also carries count (requests affected) and names (a few of the unresolved names, capped); pre_request_script_skipped carries steps (how many carried one). Absent, not an empty array, for a run with nothing to report - which is every run before this field existed and every run that genuinely had nothing to say. unresolved_tokens covers every load shape alike (issue #1540): a single-request run with no data set and a scenario step report the same warning for the same mistake.

lifecycle (issue #1499) is script.setup / script.teardown's outcomes - what ran once at the run's own boundary, never at a step. Each key is present only when that phase ran at least one element (ElementOutcome's usual shape: id, kind, outcome, message?, waitedMs?, wrote?); absent entirely, not {}, for a run whose collection (or, for a single-request run, whose own lifecycleElements array - issue #1573) declared neither. A script.setup outcome other than "ok" already means the run never sent anything - status is Failed and every other section above is absent or zeroed - so a reader who finds one here knows why the rest of the report is empty. A script.teardown outcome of "error" carries the thrown message but never changes status: a teardown failure is reported, not fatal.

A streaming run adds a stream section and no other run carries one:

"stream": {
  "completions": 480,
  "totalEvents": 19200,
  "capped": 480,
  "eventsPerSecond": 320.0,
  "events": { "min": 40, "max": 40, "p50": 40, "p90": 40, "p95": 40, "p99": 40, "count": 480 }
}

Absent for every run that did not stream, and for one started with stream_metrics: false - not zeroed, because "this run was not a stream" and "this stream delivered nothing" are different answers, and a run whose target closed every connection before the first event is where telling them apart matters.

events is the per-completion distribution, not the run's total: 480 streams of 40 events each have a p50 of 40, not of 19200. eventsPerSecond is the whole-run rate, derived from the same testDuration the report's rps uses so the two are comparable. Both are reported because one long stream and 250 short ones can share a rate while being entirely different runs.

capped counts the completions a cap ended rather than the server. In the example above every stream hit the event cap - the events percentiles all sitting exactly on it is the tell - so those counts measure the caps, not the target.

Time to first event needs no field of its own: it is timingBreakdown.phases.firstByte, since a stream's first byte is its first event's first byte. A second copy would be a second number to keep true.

A capacity run adds a capacity section and no other mode carries one:

"capacity": {
  "sloMs": 200,
  "stopReason": "slo_exceeded",
  "maxHealthyConcurrency": 48, "maxHealthyRps": 23400, "p99AtMaxHealthyMs": 41.2,
  "kneeConcurrency": 64, "kneeP99Ms": 312.0,
  "levels": [ { "concurrency": 1, "rps": 980, "p99Ms": 1.4 } ]
}

stopReason is one of slo_exceeded, plateau, cap_reached, deadline or stopped - see Capacity semantics. The two optional halves are omitted rather than zeroed, and the distinction carries information:

  • maxHealthy* is absent when the very first level already breached the budget. The search found no sustainable capacity, which is not the same claim as a capacity of zero.
  • knee* is absent unless stopReason is slo_exceeded. A run that ended at its ceiling, its deadline, or on a plateau inside the budget never watched the target give out, so it has no knee to report.

levels[] is one entry per level judged, in order - bounded by construction (a search holds tens of levels, not thousands). A level that breached once and was re-measured appears twice, at the same concurrency; the level still being measured when the run ended does not appear at all, because it was never judged.

auth appears only for a run whose OAuth 2.0 credential could be renewed while it ran - a header-placed, expiring token with autoRefreshToken on (see db-schema.md for the full eligibility list). Each entry in refreshes is when a renewal landed, in seconds from the run's start. refreshFailures plus a lastError string is the other half of the answer: the run kept sending the credential it had, so 401s in statusCodes from that point on are explained here rather than by the target. The section is absent for a run that could never refresh, which is not the same claim as a run that watched and never needed to (that one reports an empty refreshes array).

A scenario run adds a scenario section and no other run type carries one:

"scenario": {
  "iterations": 3, "iterationsCompleted": 3, "stepsExecuted": 6,
  "passed": 4, "failed": 1, "skipped": 0, "errored": 1,
  "stepsStored": 6, "stepsDropped": 0,
  "transactions": [
    { "name": "checkout", "count": 3, "errors": 0,
      "latency": { "min": 8.1, "p50": 14.2, "p90": 22.0, "p95": 26.5, "p99": 33.0, "max": 55.4 } }
  ]
}

stepsStored versus stepsExecuted is the honest reading of results[]: a run that filled maxScenarioStoredSteps reports fewer rows than it ran, with every non-passing step among the ones kept. summary.totalRequests is the number of step executions, not the number of rows that survived.

transactions (issue #1515) is a sibling of steps, not a member of it - a control.transaction spans a folder, not one step - and is absent, never [], for a run with no control.transaction element or one whose folder never closed. One entry per declared name that closed at least once, in the same shape scenario.transactions documents elsewhere on this page.

latency.* and the enriched summary fields (peakConcurrency, droppedRequests, avgQueueWaitMs, bytesSent/Received, throughputBytesPerSec) come from the persisted per-tick metrics rows. latency_ms in results (and therefore these percentiles) is perceived latency.

summary.httpVersionDowngraded is the count of this run's transfers that asked for HTTP/2 and negotiated something older - see httpVersionDowngraded on a response. It is the only figure in summary that describes the report's validity rather than its performance: non-zero means the latency and throughput beside it were measured over a protocol other than the one metadata.configuration.httpVersion names.

0 is "none recorded", not "none happened". An engine from 0.15.0 always emits the key - including for a run whose stored summary predates the count, and for one reported from its sampled results because that summary was malformed or never written, neither of which can produce a figure. The key is absent only from an engine older than 0.15.0. That is deliberately a weaker guarantee than the per-response httpVersionDowngraded, which is exact for the exchange it describes.

sampling says how much each bounded store thinned away: all zeros means the results[] array and the tested responses are the complete set, and a non-zero count means they are a uniform sample of the whole run (reservoir retention) rather than a truncated prefix of it. The section is absent on a run recorded before it was reported, which is not the same claim as "nothing was dropped".

responseSamplesDropped counts responses the post-run test scripts and schema checks never saw, for either of the two bounds on that store: the count cap (max_response_samples), and the byte budget (max_response_sample_bytes) that drops a whole sample once the run's retained bodies would exceed it. Both mean the same thing to a reader - this response was not validated - and neither ever stores a cut body, because a deferred check reading one reports a failure the target never produced. They differ in one way worth knowing: the count cap displaces an incumbent, so the tested set stays a uniform sample of the run, while an exhausted byte budget stops admitting - a run that spends it is graded on the part of the run whose bodies fit. The counter cannot say which bound applied on its own; responseSampleBudgetSpent is the answer (issue #1192). true means the byte budget ended at least one sample, so the tested set is drawn from the part of the run whose bodies fit; false means only the count cap displaced anything, so the tested set stays a uniform sample of the whole run. It is written by every run recorded since the marker existed, and is absent on a summary written before that - absent means "not known", not "not spent", which matters because weakening the uniform claim for every pre-marker run would cost the accurate message in the case that is nearly all of them: only a target whose retained bodies average more than ~256 KiB reaches the budget at the defaults. The app's retention note drops its uniformity sentence only when the key reads true; absent or false both keep it.

Three of its keys are about captured responses rather than retention: responseBodiesCaptured is how many exchanges the run stored (see GET /runs/:runId/samples), and is also the run's own marker that it holds response data stored verbatim - non-zero is what the Samples tab warns on. sampleBodiesDropped counts samples whose headers were kept but whose body was dropped once the run's maxSampleBytes budget was spent. exemplarsDropped counts per-status exemplars refused because max_exemplar_results was full, which only a target answering with more distinct status codes than that limit can reach. All three are absent on runs recorded before 0.15.0.

monitor is present only for a run that declared a monitor block - absent is "this run scraped nothing", never "the target reported zeros". samples counts successful scrapes and failures counts the ones that read nothing, so a section with samples: 0 and a non-zero failures says the endpoint was unreachable for the whole run rather than that the run was not monitored. A series that never produced a reading is absent from series for the same reason. The per-sample readings are served separately by GET /runs/:runId/monitor.

results[].id is the results row id, and the join key against GET /runs/:runId/samples. It is absent on reports served by an engine older than 0.15.0.

metadata.configuration carries the load-test tuning knobs present in the snapshot (mode, duration, concurrency, startConcurrency, rampUpDuration, timeout, comment, followRedirects, maxRedirects - each omitted when absent) plus httpVersion, which is always present with the same "auto"-when-unknown normalization GET /runs's summary uses (see above), and acceptEncoding (issue #1488), read out of the snapshot's defaultHeaders and omitted on the same terms as GET /runs's summary key of the same name. rps in the raw snapshot is renamed to targetRps here.

GET /runs/:runId/samples

Get the response headers and body a load run captured for its retained samples. Paginated; no deprecated alias - the endpoint is new in 0.15.0, so there is no pre-consolidation spelling for it to keep working.

Deliberately not part of GET /runs/:runId/report. That path loads every results row for the run and JSON-parses each trace_data to accumulate aggregates that never look at a body, and the dashboard polls it; at 1000 samples carrying 32 KiB bodies, folding them in would turn every poll into ~32 MB read from SQLite and parsed. So the bodies live in their own tables and are fetched here, per page, only when a reader actually expands a sample.

Query params:

Param Default Notes
limit 50 Capped at 500; a non-numeric value falls back to the default
offset 0 Floored at 0

What a run captures - and does not - is described under result_bodies: every error, the slow outliers, and the first three of each distinct status code, within maxSampleBodyBytes per body and maxSampleBytes for the run. A uniformly sampled success carries no body by design.

Response:

{
  "data": [
    {
      "resultId": 41,
      "response": {
        "headers": { "content-type": "application/json", "server": "nginx" },
        "body": "{\"error\":\"upstream timeout\"}",
        "bodyBytes": 28,
        "contentType": "application/json"
      }
    },
    {
      "resultId": 42,
      "response": {
        "headers": { "content-type": "image/png" },
        "bodyBytes": 20480,
        "contentType": "image/png",
        "binary": true
      }
    }
  ],
  "pagination": { "total": 23, "limit": 50, "offset": 0, "hasMore": false, "returned": 23 }
}

resultId is the results row this exchange belongs to - join it against results[].id on the report rather than re-deriving an order.

The response node always carries headers and bodyBytes (the size as received, before any truncation). The rest is conditional, and each key means something the bytes alone cannot say:

Key When present Meaning
body Not binary The stored bytes. "" when the response had none, or when the body was dropped
bodyTruncated true only body is a prefix; the response was larger than maxSampleBodyBytes. Same convention design-mode traces use
bodyDropped true only The run's maxSampleBytes budget was spent before this sample: headers kept, body not. Distinct from an empty response body
binary true only Stored as a descriptor - bodyBytes and contentType, no bytes. See result_bodies for why a binary body is never stored as text
contentType Non-empty The response's Content-Type
events Streams only What the stream delivered, in the trace's events shape (see below). Absent for every sample that did not stream

A streamed sample carries its events (0.17.2). events is {items, totalEvents, eventsTruncated} - the same node a streaming design run stores on its trace, minus endReason: under load a stream ends by server close or by one of two caps (maxStreamEvents / maxStreamDurationMs) and nothing per sample records which, so no reason is named rather than one invented. The list is parsed out of the stored body on read, bounded by sseMaxStoredEvents, and totalEvents is the count taken on the wire - so a capture the maxSampleBodyBytes cap cut still reports the whole stream's length, with eventsTruncated covering both that cut and the stored-events cap. See result_bodies for why the count is stored and the list is not.

"events": {
  "items": [ { "event": "token", "data": "hello", "sourceId": "42" } ],
  "totalEvents": 4000,
  "eventsTruncated": true
}

Captured data is stored verbatim - no redaction, consistently with design-mode traces, which already store request headers as sent. A response Set-Cookie is captured along with everything else. It is deleted with the run, so maxRunsRetained is its expiry.

A run that captured nothing is an empty page, not an error; an unknown run id is a 404 in the shared error shape.

Response (404):

{ "error": { "code": 404, "message": "Run not found" } }

PUT /runs/:runId/baseline

Pin (or unpin) any run - design, load or scenario alike. PUT rather than POST per the create vs update split: the run already exists, and this updates it. There is no deprecated alias; the endpoint is new.

Two things follow from a pin, and both are the point of it:

  • Retention never expires it. prune_runs skips a baseline under both the count cap and the age cap, and a pinned run does not count toward maxRunsRetained either - a pin the cap could expire is not a pin, and pins crowding the cap would evict the recent history the cap exists to keep.
  • Clients can find it: GET /runs?baseline=true&requestId=<id>&limit=1.

The engine's own meaning of the flag stops there - it holds no opinion about why a run is kept. A client's vs-baseline comparison is a narrower, load-only reading of the same flag: only a load run has the percentiles and throughput a diff needs, so a comparison lists type=load alongside baseline=true (GET /runs?baseline=true&type=load&requestId=<id>&limit=1) rather than treating any pinned row as its baseline - otherwise a more-recently-pinned design or scenario run of the same request would shadow it.

Several runs may be pinned at once (one per request is the expected use). The engine records the pin and holds no opinion about which baseline applies to which run - that selection is the client's, so a pin never unpins anything else.

Request body - baseline is required and must be a boolean:

{ "baseline": true }

Response 200: the updated run row, in the same shape GET /runs lists (including summary), so a client can patch its cached row instead of re-listing.

400 when the body is not JSON, has no baseline, or baseline is not a boolean - including null. Unlike the merge-patch resource updates, an unusable value here is refused rather than ignored: this body has exactly one field, so ignoring it would answer 200 to a request that changed nothing.

{ "error": { "code": "bad_request", "message": "Invalid 'baseline': must be a boolean" } }

404 when no run has that id.

DELETE /runs/:runId

Alias: DELETE /run/:runId (deprecated - see Deprecated aliases).

Delete a run and all associated metrics/results.

An active run is stopped first. Deleting a run that is still executing used to remove its rows while its worker kept writing new metrics and results against the same id - orphan rows that no run owns, and a run that partially reappeared as it finished. So a run that is still active is stopped exactly as POST /runs/:runId/stop stops it, and the rows are removed only once its worker has completed its final writes. Expect the call to take as long as the stop does (up to ~5s for a large run).

If the worker has not settled within that window nothing is deleted and the call returns 409 - a half-deleted run racing a live writer is worse than a delete that has to be retried. The stop still stands, so a retry a moment later succeeds. A run whose stored status is running but which has no worker (the daemon restarted under it) has nobody to race and is deleted immediately.

Response:

{
  "message": "Run deleted successfully",
  "runId": "run_1234567890"
}

409 Conflict (still stopping - nothing was deleted):

{
  "error": {
    "code": "conflict",
    "message": "Run is still stopping; it was not deleted. Retry once it reports a terminal status."
  }
}

Scripting

GET /scripting/completions

Get script engine API completions for UI autocomplete.

Response:

{
  "version": "1.0.0",
  "engine": "quickjs",
  "completions": [
    {
      "label": "pm.test",
      "kind": 1,
      "insertText": "pm.test(\"${1:test name}\", function() {\n\t${2:// assertions}\n});",
      "detail": "pm.test(name: string, fn: () => void)",
      "documentation": "Define a test with assertions..."
    }
  ]
}

An entry whose kind is 28 (Monaco's Snippet kind) carries two extra members no other entry does: context, one of "pre", "test" or "both", which script kind the template belongs in; and group, one of "Variables", "Request", "Response", "Tests", "Signing" or "Logging", the heading it is listed under. A client uses the pair to build a filtered, grouped snippets picker for whichever panel - Pre-request or Tests - the author has open, rather than offering the whole table regardless of where it would run. Neither field is present on a non-snippet entry.

GET /scripting/types

The same pm.* surface as TypeScript declarations, for Monaco's TypeScript worker. A completion list can only populate a dropdown; the declarations are what give hover documentation over an existing call, signature help while typing arguments, and go-to-definition within the surface.

The declarations are generated from the completion table above, not maintained separately - a hand-written pm.d.ts in the app would be a second declaration of a surface the engine owns, and the two would drift the first time a method was added to one and not the other. The derivation works because a completion entry already carries its type: a function's detail is its signature, a field's detail is its type. See engine/src/http/routes/script_types.cpp.

Output is deterministic - the same table always produces byte-identical text, so a client may cache on version.

Response:

{
  "version": "1.0.0",
  "engine": "quickjs",
  "libUri": "ts:vayu/pm.d.ts",
  "typeDefinitions": "interface VayuExpectation {\n\tequal(expected: any): VayuExpectation;\n…"
}

Field Description
libUri Model URI the app registers the declarations under (addExtraLib)
typeDefinitions The .d.ts source

The file also declares the host globals the sandbox lacks (setTimeout, fetch, require, URL, …) as never, with the reason as documentation. That is not padding: the app must suppress "Cannot find name" wholesale, because a collection-level script part is joined to the request's before the engine runs it, so a name declared there is undeclared as far as the editor's model can see. Declaring the absent globals keeps the real mistake caught ("not callable") while that suppression is in force. A test executes typeof <name> in the real script engine for every entry, so the list cannot drift from the runtime.

The assertion chain is one interface, read by leaf rather than by path (#1209). A completion label is a dotted spelling - to.have.deep.property is what someone types - and not the shape of the object: create_expectation installs to, have, deep and property on the same expectation, and every one of them hands that expectation back. So the generator takes each label's last segment as the member and every earlier segment as evidence that word is a chainer, and emits a single VayuExpectation where every chain word is a property of that type and every matcher a method returning it - chai's own model. Read as a path tree it emitted two interfaces instead, and a matcher was reachable only along the one route some label happened to spell, so .to.be.a('string').and.match(/x/) was an editor error on a chain the engine runs. Where two labels share a leaf (to.have.property, to.have.deep.property, to.have.nested.property), the plainest spelling declares the member and the longer ones fold their documentation into its hover under their own labels.

The guard is set equality rather than a list of names: TheDeclaredChainIsTheObjectTheRuntimeBuilds reads the interface's members out of the generated text and the expectation's out of the real script engine (Object.getOwnPropertyNames, so no terminal getter is triggered) and fails on a member either side has alone. A matcher added to the runtime and not to the table now reddens instead of being missing.

Two things the generated file still cannot get from the table, both handled in script_types.cpp and guarded by script_types_test.cpp:

  • Which label roots open the chain. that.is.not.empty is a chain and console.log is a global, and nothing in either entry says which; the chain roots (to, and, and chai's twelve language chains) are named in the generator, so a label rooted at one is filed under the chain rather than declaring a top-level that nothing binds.
  • Unparseable parameter lists. Two entries document an overload in prose TypeScript cannot parse (upsert({ key, value }) | (name, value)). Those fall back to (...args: any[]), keeping the member callable rather than emitting a file that does not compile.

The declarations are compiled, not just grepped

script_types_test.cpp asserts on substrings of the generated text - every listed member appears, the chain returns the chain, an optional field keeps its type. That cannot catch the class of defect where a declaration contains every right name and still does not type-check, and four of those shipped (#463): pm.cookies.jar() emitted twice with the object overload winning, every jar method's signature emptied by the () inside its own label, pm.info.eventName read as prose and typed void, and two pm.expect chains shorter than the documentation beside them.

So the durable guard compiles every ```javascript block mentioning pm. in scripting.md and pm-api-compatibility.md against the real declarations and requires zero errors, with the app's own compiler options and its two suppressed codes. The documentation and the declarations then hold each other up: an example the editor would squiggle fails the suite, and so does a declaration that stops describing what the docs recommend.

It needs a TypeScript compiler, which ctest does not have, and the generator needs the engine, which vitest cannot run. The two halves meet at one checked-in artifact - the same shape as variable-resolution-conformance.json:

Where What
engine/tests/fixtures/script-typedefs.d.ts The generated declarations, checked in
ScriptTypesTest.TheCheckedInDeclarationsMatchTheGenerator Pins that file to the generator byte-for-byte, so the copy cannot drift
app/src/hooks/script-typedefs.docs-compile.test.ts Compiles the docs' blocks against it

A change to the surface therefore shows up as a diff in the declarations the editor will serve. Regenerate deliberately:

VAYU_UPDATE_SCRIPT_TYPEDEFS=1 ctest --preset linux-dev -R ScriptTypes

HTTP Status Codes

Code Meaning
200 Success
400 Bad request (invalid JSON, missing required fields, invalid OAuth 2.0 config)
401 OAuth 2.0 provider rejected the token request
404 Resource not found
409 OAuth 2.0 interactive authorization required (/run pre-flight, /oauth2/token)
500 Internal server error
502 Upstream network error (OAuth 2.0 token endpoint, /import/fetch proxy)
503 Engine is shutting down (POST /runs only - see below)

Notes

  • All timestamps are Unix milliseconds (since epoch)
  • Variable substitution uses {{variableName}} syntax
  • Environment variables are resolved in order: environment → collection → global
  • Load test metrics are collected every 100ms
  • SSE connections timeout after 30 seconds of inactivity