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:
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-urlencodedsetsContent-Type: application/x-www-form-urlencodedonly when the request declares no Content-Type of its own - an explicit header wins.jsonsetsContent-Type: application/jsonunder the same rule (issue #889). It did not until then, and libcurl's default for a POST carrying a body isapplication/x-www-form-urlencoded- so a request whosebody.modesaidjsonwent 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 - MCPcreate_request, an import, a payload posted straight to/execute- sent the wrong type. A declared header still wins, soapplication/vnd.api+jsonreaches the server unchanged.graphqlandjsonrpcsetContent-Type: application/jsonunder the same rule - a Content-Type the caller wrote wins.graphqlonly on the method that carries a body at all: a GraphQLGEThas no body and derives no Content-Type, since there is nothing to describe (see below).xmlsetsContent-Type: application/xml, again only when the caller declared none. It has no envelope and nothing is done tocontent; the header is the whole of what the mode adds overtext, which is why an endpoint expectingapplication/soap+xmlkeeps the header it was given.form-dataalways 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-urlencodedbody is a400- that media type's wire form is a string of pairs and has no file form. - A
srcon a part that is not"type": "file"is a400: it names a file nothing would send. - An enabled file part whose
srcis 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 onPOST /executeandPOST /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
idsilently created a second record instead of failing, and anidcollision silently merged two records into one. POST /environmentshad no null guard, so{"variables": null}stored the literal four-character textnull- 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.isActivewas honored only on create; it now follows the rule on both verbs.
Health & Configuration¶
GET /health¶
Check engine status and version.
Response:
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
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 MCPupdate_configresult at the same time. Exactly three entries carry it -dbCacheSize,dbBusyTimeoutanddbSynchronous, all read when the database is opened.workerscarried 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.cpppins the set.advanced- an internal with no everyday user story (dbBusyTimeout, the fouroauth2Refresh*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 inheritedno_proxyis 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 exportingno_proxy=...,127.0.0.1,...bypasses a configured proxy for every local target and says nothing. - Under
environment, an empty list defers tono_proxy- "do what the environment says" includes the exemptions it names - and a non-empty one overrides it. - Under
systemit follows whichever of those two the mode resolved to: themanualrule once a proxy is in force, theenvironmentrule when nothing resolved. - Under
offthere 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,proxySystemUrlis empty andsystembehaves asenvironmentdoes - not asoff, 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 anAccept-Encoding: identityis sent exactly as written. A request that namesAccept-Encodingitself 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
disabledDefaultHeaderson 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-Versionalways, anX-Request-IDwhose value is a bare UUID, and aUser-Agentwhose value is aVayu/.... A correlation id or aUser-Agentsomeone 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
customCaCertificatesfixes 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:
Single - update one key:
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):
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:
parentIdequal to the collection's ownid- messageA collection cannot be its own parent.parentIdpointing at one of the collection's own descendants (a reparent that would form a cycle) - messageCannot 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).
{} 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).
{} 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:
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):
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:
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 (
parentIdcleared). A request has no such root -collectionIdis required - so restoring one whose collection is in the trash is a409naming 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:
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
pathsand for each operation'sresponses. - Both formats:
openapi: 3.xandswagger: 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
$refis 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,
traceis not. - A repeated
operationIdis 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 a400naming 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:
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:
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:
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
deletehere 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/diffreports every removal before anything is written, the app renders them as ticks the user unticks one by one, andpolicy: "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 issuesDELETE /requests/:idper row, which is soft. - The bound subtree is the boundary. Every request an
updateor adeletenames, and every collection a created request lands in, must be the collection being synced or one beneath it. Anything else is a400naming the item - without this the route would be a way to delete any row by id. - The engine mints every id, as
/import/applydoes: new rows are named bytempIdand translated throughidMap, and a bodyidis a400. examplesis a decision, and the rows are the document's (issue #869).trueon an update refreshes that request's imported examples from the document this call is storing; absent (orfalse) leaves every example alone. A created request always gets the responses its operation documents, soexampleson acreateitem is a400- 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 a400too, rather than a silent no-op. Rows withorigin: "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.
collectionIdinside anupdateitem is a400; usePUT /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:
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
nullmeans the engine's transport ceiling, 256 MiB. So does a larger value: a bound over the ceiling is clamped to it rather than refused, becausemaxSpecDocumentBytescan be raised to 100 MiB and an import the setting allows must not be turned into a400. - Anything that is not a positive integer -
0, a negative, a string, a fraction - is a400. 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
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
400above, 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
errorevent, not a status. The response headers left before the download began, so the200is already spent - which is why that event carries the numericstatusitself. The standard error body could not: itscodeis a slug, and413and404both slug to plainerror. - 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
Acceptgets the buffered JSON unchanged, which is what$refbundling, 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
nullmeans "none of that kind" (the null-vs-absent rule). An empty payload is a200with an emptyidMap. - Every item needs a non-empty string
tempId, unique across the four tree sections (they share one namespace, becauseidMapis one flat map) -clientCertificatesitems carry notempIdat all, see below. Temp ids are never stored. - A collection's
parentTempIdand a request'scollectionTempIdmust name a collectiontempIdin the same payload; references may point forward, so a child may appear before its parent.parentTempIdisnull(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.idis not accepted here: the engine owns ID generation on this path. orderis optional. For collections, an omittedorderappends 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 to0on this path only:POST /requestsappends 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 omitsorderon its root collections, so an import into a non-empty workspace lands after the roots already there instead of colliding with their0, 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 notempId, get engine-generated ids, and so do not appear inidMap. Each entry takes the fieldsPOST /requests/:id/examplesaccepts, through the same field applier, and an entry that fails validation is a per-item400naming the request'stempId.originis among those fields and an importer leaves it at its"import"default, which is what these rows are. An entry with noordertakes its payload position, so the stored order is the order the source file listed the responses in. - A
specsitem carriescontent(required, non-empty) and an optionalsourceUrl; itshashandfetchedAtare engine-computed and a per-item400if sent, and the size cap is the same livemaxSpecDocumentBytesthatPOST /specsenforces, 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 ascollectionTempIdis) oropenapi.specId(one already stored). Sending both is a per-item400, and so is either one that resolves to nothing. The resolved value is stored asopenapi.specId;specTempIdis never persisted. The binding'sspecHashandsyncedAtare stamped by the engine from the document - the one this payload just wrote, or the stored one anopenapi.specIdnames - so an imported collection is bound to a version and its runs are measured against the contract (issue #709). - A
clientCertificatesitem is aclient_certificatesregistry candidate (issue #1656), in the same field shapePOST /client-certificatestakes (host,port,certPath,keyPath,certFormat,passphrase) -idis refused here too. Unlike every other section it carries notempId(nothing else in the payload references a certificate) and is applied after the tree's own transaction commits rather than inside it: the engine reusesPOST /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 anitemerror - 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 ownclientCertificatesarray (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:
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:
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:
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 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.
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 byengine/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/mineanswersGET /pets/mineeven 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 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.
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_codeandrefresh_tokengrants,application/x-www-form-urlencodedas 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 and302s straight back toredirect_uriwithcodeandstate, so the interactive flow completes with zero human steps. PKCE is verified when acode_challengeis present;code_challenge_methodmust then beS256(plainis refused rather than quietly accepted). An unknown client or a missingredirect_urianswers 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 ornullmeans no dataset: composition resolves exactly as it did before this field existed. Refused with400invalid_compose_requestwhen the field is present and not an array, or holds an entry that is not a string or is the empty string. Adata.-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,nullorfalsecomposes exactly as it did before this field existed, which is what a Send, a preview and a script'sreplaceInwant: composed once, sent once. Refused with400invalid_compose_requestwhen 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, becauseapply_authencodes 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. -
requestIdcomposes the stored request wholesale: URL, flattened enabled headers (later duplicates win), body, auth (absent auth defaults toinherit), the resolvedelementslist (collection chain rootāleaf, then the request's own, each stamped with its origin, disabled and blankscript.*entries dropped the same way - see Elements), the stored execution options (followRedirects/maxRedirects/httpVersion/verifySSL, always emitted) and itsrequestName(the script sandbox reads it aspm.info.requestName; omitted when the row's name is empty). The request's own collection scopes resolution;collectionIdis only a fallback for a request without one. An unknown id is a definitive 404. requestis an inline unresolved request in thePOST /executebody shape. Given alongsiderequestId, its fields lay over the stored ones before resolution - how an override like "retarget this saved request at another URL" works. Given alone,collectionIdscopes the variable chain and theinheritwalk. 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": "..."}}- unknownrequestId.400{"error": {"code": "invalid_compose_request", "message": "..."}}- malformed JSON, neitherrequestIdnorrequest, 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, sook\r\nX-Admin: truearriving 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 headerkey + ": " + valueforcurl_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.
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": truewith"transient": true- a stream is its run row: the row is whateventsUrlnames, what carries the status, and what a stop finds, and a transient execution creates none;streamonPOST /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:
iterationsis 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
databeside ascenarioblock is a400namingscenario.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):
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.
scriptsdecides whether ascript.pre/script.postelement runs inline (on the producer/completion hooks, before or after the step's own send) or stays deferred to the post-runtestsreplay."asMarked"(the default) reads each element's ownconfig.inline;"allInline"/"allDeferred"force everyscript.*element of the run one way, regardless of its own marking.extract.*andassert.*are unaffected - they always run inline on a scenario load run.includeScriptTimefolds the element pipeline's own elapsed time into a step's recorded latency whentrue; 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 everytimer.*element's own stored config unchanged;"off"silences everytimer.*element for the run (waitedMs: 0, no sleep);{"fixedMs": N}or{"minMs": N, "maxMs": N}replace everytimer.*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 rulescriptsabove uses.timer.think'sstep.betweenphase now dispatches on a scenario load run too (non-blocking, summed intoVirtualUser::ready_at_ms), so this override reaches load runs as well as the sequential run.timer.pacingandtimer.throughputschedule their wait before that step'sElementContextexists 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 withtimers: "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 owneveryMs/targetPerMinute.seed(issue #1498) is an optional non-negative integer that seeds this run's RNG, making atimer.thinkelement'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 fromstd::random_deviceas 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, sonode_cpu_seconds_total{cpu="0"}and{cpu="1"}chart as one series.json- a flat object of numbers, whereserieslists 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 throughpm.environment.get(...)and apm.requestedit, 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'sBearer {{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 (issue994). 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
dataColumnsnames 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 base64Authorization, 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 anoauth2config is a400atPOST /runsnaming 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:
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 -
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
typeisload, notscenario. It publishesmetricsticks overGET /runs/:runId/live(notstepevents) 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.*andassert.*always run - before the send (step.before) and after the response (step.after). Ascript.pre/script.postelement runs there only when its ownconfig.inlineistrue, or the run'selementsoverride 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 (anextract.json's target, an inline script'spm.environment.set) is visible only to that same user's later steps, never to another user's concurrent one.pm.executionstill throws - a script cannot redirect the sequence under load. A controller element can (issue #1515):control.if,control.onceandcontrol.throughputskip a step the waypm.execution.skipRequest()would, counted in the run's summaryskippedkey rather than the0every scenario load run reported before this;control.switchandcontrol.loopjump the plan (issue #1569), resolved the same way a script's ownsetNextRequestwould be and guarded by the samemaxStepsPerIterationcycle limit the sequential run uses.control.throughput'sperUser: falseshares one budget across every virtual user instead of one per user, andcontrol.transaction'sincludeTimersfolds a between-membertimer.*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(seescenario.stepsbelow). A step that carries no script, whose script ran inline this run, or whose script never got a sampled response, carries notestsobject 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:
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/offsetcount ticks, sopagination.totalis the number of ticks (not rows), and a page boundary can no longer return a tick with half its fields zeroed. elapsed_secondskeeps 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/:runIdis 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 ofGET /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:
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}
openis published once, as soon as the response's header block arrives, so even a late consumer learns what the stream connected to.messagecarries one upstream event: itseventname (messagewhen the origin sent none), itsdata(multipledata:lines joined with\n), the origin's ownid:assourceId, andreceivedAt. An event larger thansseMaxEventBytesis stored as a prefix and says so in band, withdataTruncated: trueanddataBytes(the size as sent) - never silently cut.complete'sreasonis one ofcompleted,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
}
]
{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:
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 unlessstopReasonisslo_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):
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_runsskips a baseline under both the count cap and the age cap, and a pinned run does not count towardmaxRunsRetainedeither - 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:
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.
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:
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.emptyis a chain andconsole.logis 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-levelthatnothing 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:
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